mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
refactor: remove demo mode — all auth goes through sidecar, call desk is live-only
This commit is contained in:
@@ -1,232 +1,38 @@
|
|||||||
import { useState, useCallback, useRef } from 'react';
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
|
import { useData } from '@/providers/data-provider';
|
||||||
|
import { useLeads } from '@/hooks/use-leads';
|
||||||
|
import { useSip } from '@/providers/sip-provider';
|
||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
import { CallSimulator } from '@/components/call-desk/call-simulator';
|
|
||||||
import { IncomingCallCard } from '@/components/call-desk/incoming-call-card';
|
import { IncomingCallCard } from '@/components/call-desk/incoming-call-card';
|
||||||
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||||
import { CallLog } from '@/components/call-desk/call-log';
|
import { CallLog } from '@/components/call-desk/call-log';
|
||||||
import { DailyStats } from '@/components/call-desk/daily-stats';
|
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 { useCallEvents } from '@/hooks/use-call-events';
|
|
||||||
import { useSip } from '@/providers/sip-provider';
|
|
||||||
import { BadgeWithDot } from '@/components/base/badges/badges';
|
import { BadgeWithDot } from '@/components/base/badges/badges';
|
||||||
import { formatPhone } from '@/lib/format';
|
import { formatPhone } from '@/lib/format';
|
||||||
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 isToday = (dateStr: string): boolean => {
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return (
|
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
|
||||||
d.getFullYear() === now.getFullYear() &&
|
|
||||||
d.getMonth() === now.getMonth() &&
|
|
||||||
d.getDate() === now.getDate()
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const dispositionToStatus: Partial<Record<CallDisposition, LeadStatus>> = {
|
|
||||||
APPOINTMENT_BOOKED: 'APPOINTMENT_SET',
|
|
||||||
FOLLOW_UP_SCHEDULED: 'CONTACTED',
|
|
||||||
INFO_PROVIDED: 'CONTACTED',
|
|
||||||
NO_ANSWER: 'NEW',
|
|
||||||
WRONG_NUMBER: 'LOST',
|
|
||||||
CALLBACK_REQUESTED: 'LOST',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CallDeskPage = () => {
|
export const CallDeskPage = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { calls, leadActivities, campaigns, addCall } = useData();
|
const { calls, leadActivities, campaigns } = useData();
|
||||||
const { leads, updateLead } = useLeads();
|
const { leads } = useLeads();
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
// --- SIP phone state ---
|
|
||||||
const { connectionStatus, isRegistered } = useSip();
|
const { connectionStatus, isRegistered } = useSip();
|
||||||
|
|
||||||
// --- 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(() => {
|
|
||||||
if (localCallState !== 'idle') return;
|
|
||||||
|
|
||||||
// Prefer leads with aiSummary, fall back to any lead
|
|
||||||
const leadsWithAi = leads.filter((l) => l.aiSummary !== null);
|
|
||||||
const pool = leadsWithAi.length > 0 ? leadsWithAi : leads;
|
|
||||||
if (pool.length === 0) return;
|
|
||||||
|
|
||||||
const randomLead = pool[Math.floor(Math.random() * pool.length)];
|
|
||||||
setLocalActiveLead(randomLead);
|
|
||||||
setLocalCallState('ringing');
|
|
||||||
setCompletedDisposition(null);
|
|
||||||
|
|
||||||
ringingTimerRef.current = setTimeout(() => {
|
|
||||||
setLocalCallState('active');
|
|
||||||
callStartRef.current = new Date();
|
|
||||||
}, 1500);
|
|
||||||
}, [localCallState, leads]);
|
|
||||||
|
|
||||||
// Demo mode: log disposition locally
|
|
||||||
const handleDemoDisposition = useCallback(
|
|
||||||
(disposition: CallDisposition, notes: string) => {
|
|
||||||
if (localActiveLead === null) return;
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const startedAt = callStartRef.current ?? now;
|
|
||||||
const durationSeconds = Math.floor((now.getTime() - startedAt.getTime()) / 1000);
|
|
||||||
|
|
||||||
const newCall: Call = {
|
|
||||||
id: `call-sim-${Date.now()}`,
|
|
||||||
createdAt: startedAt.toISOString(),
|
|
||||||
callDirection: 'INBOUND',
|
|
||||||
callStatus: 'COMPLETED',
|
|
||||||
callerNumber: localActiveLead.contactPhone,
|
|
||||||
agentName: user.name,
|
|
||||||
startedAt: startedAt.toISOString(),
|
|
||||||
endedAt: now.toISOString(),
|
|
||||||
durationSeconds: Math.max(durationSeconds, 60),
|
|
||||||
recordingUrl: null,
|
|
||||||
disposition,
|
|
||||||
callNotes: notes || null,
|
|
||||||
patientId: null,
|
|
||||||
appointmentId: null,
|
|
||||||
leadId: localActiveLead.id,
|
|
||||||
leadName:
|
|
||||||
`${localActiveLead.contactName?.firstName ?? ''} ${localActiveLead.contactName?.lastName ?? ''}`.trim() ||
|
|
||||||
'Unknown',
|
|
||||||
leadPhone: localActiveLead.contactPhone?.[0]?.number ?? undefined,
|
|
||||||
leadService: localActiveLead.interestedService ?? undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
addCall(newCall);
|
|
||||||
|
|
||||||
const newStatus = dispositionToStatus[disposition];
|
|
||||||
if (newStatus !== undefined) {
|
|
||||||
updateLead(localActiveLead.id, {
|
|
||||||
leadStatus: newStatus,
|
|
||||||
lastContactedAt: now.toISOString(),
|
|
||||||
contactAttempts: (localActiveLead.contactAttempts ?? 0) + 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setCompletedDisposition(disposition);
|
|
||||||
setLocalCallState('completed');
|
|
||||||
|
|
||||||
completedTimerRef.current = setTimeout(() => {
|
|
||||||
setLocalCallState('idle');
|
|
||||||
setLocalActiveLead(null);
|
|
||||||
setCompletedDisposition(null);
|
|
||||||
}, 3000);
|
|
||||||
},
|
|
||||||
[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 */}
|
{/* Status 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>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BadgeWithDot
|
<BadgeWithDot
|
||||||
color={isRegistered ? 'success' : connectionStatus === 'connecting' ? 'warning' : 'gray'}
|
color={isRegistered ? 'success' : connectionStatus === 'connecting' ? 'warning' : 'gray'}
|
||||||
@@ -235,29 +41,10 @@ export const CallDeskPage = () => {
|
|||||||
>
|
>
|
||||||
{isRegistered ? 'Phone Ready' : `Phone: ${connectionStatus}`}
|
{isRegistered ? 'Phone Ready' : `Phone: ${connectionStatus}`}
|
||||||
</BadgeWithDot>
|
</BadgeWithDot>
|
||||||
|
|
||||||
{mode === 'live' && (
|
|
||||||
<BadgeWithDot
|
|
||||||
color={isConnected ? 'success' : 'gray'}
|
|
||||||
size="md"
|
|
||||||
type="pill-color"
|
|
||||||
>
|
|
||||||
{isConnected ? 'Connected to call center' : 'Connecting...'}
|
|
||||||
</BadgeWithDot>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Demo mode simulator button */}
|
|
||||||
{mode === 'demo' && (
|
|
||||||
<CallSimulator
|
|
||||||
onSimulate={handleSimulateCall}
|
|
||||||
isCallActive={localCallState !== 'idle'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Worklist: leads with click-to-call */}
|
{/* Worklist: leads with click-to-call */}
|
||||||
{mode === 'live' && leads.length > 0 && (
|
{leads.length > 0 && (
|
||||||
<div className="rounded-2xl border border-secondary bg-primary">
|
<div className="rounded-2xl border border-secondary bg-primary">
|
||||||
<div className="border-b border-secondary px-5 py-3">
|
<div className="border-b border-secondary px-5 py-3">
|
||||||
<h3 className="text-sm font-bold text-primary">Worklist</h3>
|
<h3 className="text-sm font-bold text-primary">Worklist</h3>
|
||||||
@@ -273,21 +60,12 @@ export const CallDeskPage = () => {
|
|||||||
const phoneNumber = phone?.number ?? '';
|
const phoneNumber = phone?.number ?? '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={lead.id} className="flex items-center justify-between gap-3 px-5 py-3">
|
||||||
key={lead.id}
|
|
||||||
className="flex items-center justify-between gap-3 px-5 py-3"
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<span className="text-sm font-semibold text-primary">
|
<span className="text-sm font-semibold text-primary">{fullName}</span>
|
||||||
{fullName}
|
<span className="ml-2 text-sm text-tertiary">{phoneDisplay}</span>
|
||||||
</span>
|
|
||||||
<span className="ml-2 text-sm text-tertiary">
|
|
||||||
{phoneDisplay}
|
|
||||||
</span>
|
|
||||||
{lead.interestedService !== null && (
|
{lead.interestedService !== null && (
|
||||||
<span className="ml-2 text-xs text-quaternary">
|
<span className="ml-2 text-xs text-quaternary">{lead.interestedService}</span>
|
||||||
{lead.interestedService}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ClickToCallButton phoneNumber={phoneNumber} />
|
<ClickToCallButton phoneNumber={phoneNumber} />
|
||||||
@@ -298,14 +76,16 @@ export const CallDeskPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Incoming call card — driven by SIP phone state via the floating CallWidget */}
|
||||||
<IncomingCallCard
|
<IncomingCallCard
|
||||||
callState={effectiveCallState}
|
callState="idle"
|
||||||
lead={displayLead}
|
lead={null}
|
||||||
activities={leadActivities}
|
activities={leadActivities}
|
||||||
campaigns={campaigns}
|
campaigns={campaigns}
|
||||||
onDisposition={handleDisposition}
|
onDisposition={() => {}}
|
||||||
completedDisposition={mode === 'demo' ? completedDisposition : null}
|
completedDisposition={null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CallLog calls={todaysCalls} />
|
<CallLog calls={todaysCalls} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -42,31 +42,26 @@ export const LoginPage = () => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// If email and password are provided, try real auth via sidecar
|
if (!email || !password) {
|
||||||
if (email && password) {
|
setError('Email and password are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const { apiClient } = await import('@/lib/api-client');
|
const { apiClient } = await import('@/lib/api-client');
|
||||||
await apiClient.login(email, password);
|
await apiClient.login(email, password);
|
||||||
login(); // Also set mock auth state for the role-based UI
|
login();
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// If sidecar is down, fall back to demo mode
|
|
||||||
console.warn('Real auth failed, falling back to demo mode:', err.message);
|
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No credentials — demo mode (mock auth)
|
|
||||||
login();
|
|
||||||
navigate('/');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGoogleSignIn = () => {
|
const handleGoogleSignIn = () => {
|
||||||
login();
|
// TODO: implement Google OAuth via sidecar
|
||||||
navigate('/');
|
setError('Google sign-in not yet configured');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -229,14 +224,9 @@ export const LoginPage = () => {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
className="w-full rounded-xl py-3 font-semibold active:scale-[0.98] transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
className="w-full rounded-xl py-3 font-semibold active:scale-[0.98] transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||||
>
|
>
|
||||||
{email ? 'Sign in' : 'Demo Mode'}
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{!email && (
|
|
||||||
<p className="mt-2 text-center text-xs text-quaternary">
|
|
||||||
Leave email empty for demo mode with mock data
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user