mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
feat: post-call workflow — disposition, appointment booking, follow-up creation
ActiveCallCard now handles the full post-call flow: - Call ends → Disposition form appears (6 options + notes) - "Appointment Booked" → Opens appointment booking slideout - "Follow-up Needed" → Auto-creates follow-up in platform - Other dispositions → Logs call and returns to worklist - "Book Appt" button available during active call too - Creates Call record in platform on disposition submit - Removed auto-reset to idle (ActiveCallCard manages lifecycle) - "Back to Worklist" resets SIP state via Jotai atoms Also fixes: - All 7 GraphQL queries corrected (LINKS subfields, field renames) - Campaign edit button moved to bottom-right - Avg Response Time uses Math.abs for seed data edge case Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,22 @@
|
||||
import { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash, faPause, faPlay } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import {
|
||||
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
|
||||
faPause, faPlay, faCalendarPlus, faCheckCircle,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { sipCallStateAtom, sipCallerNumberAtom } from '@/state/sip-state';
|
||||
import { useSip } from '@/providers/sip-provider';
|
||||
import { DispositionForm } from './disposition-form';
|
||||
import { AppointmentForm } from './appointment-form';
|
||||
import { formatPhone } from '@/lib/format';
|
||||
import type { Lead } from '@/types/entities';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { Lead, CallDisposition } from '@/types/entities';
|
||||
|
||||
type PostCallStage = 'disposition' | 'appointment' | 'follow-up' | 'done';
|
||||
|
||||
interface ActiveCallCardProps {
|
||||
lead: Lead | null;
|
||||
@@ -19,6 +31,11 @@ const formatDuration = (seconds: number): string => {
|
||||
|
||||
export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
||||
const { callState, callDuration, isMuted, isOnHold, answer, reject, hangup, toggleMute, toggleHold } = useSip();
|
||||
const setCallState = useSetAtom(sipCallStateAtom);
|
||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||
const [postCallStage, setPostCallStage] = useState<PostCallStage | null>(null);
|
||||
const [savedDisposition, setSavedDisposition] = useState<CallDisposition | null>(null);
|
||||
const [appointmentOpen, setAppointmentOpen] = useState(false);
|
||||
|
||||
const firstName = lead?.contactName?.firstName ?? '';
|
||||
const lastName = lead?.contactName?.lastName ?? '';
|
||||
@@ -26,6 +43,69 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
||||
const phone = lead?.contactPhone?.[0];
|
||||
const phoneDisplay = phone ? formatPhone(phone) : callerPhone || 'Unknown';
|
||||
|
||||
const handleDisposition = async (disposition: CallDisposition, notes: string) => {
|
||||
setSavedDisposition(disposition);
|
||||
|
||||
// Create call record in platform
|
||||
try {
|
||||
await apiClient.graphql(`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`, {
|
||||
data: {
|
||||
name: `${fullName || phoneDisplay} — ${disposition}`,
|
||||
direction: 'INBOUND',
|
||||
callStatus: 'COMPLETED',
|
||||
agentName: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
durationSec: callDuration,
|
||||
disposition,
|
||||
leadId: lead?.id ?? null,
|
||||
},
|
||||
}, { silent: true });
|
||||
} catch {
|
||||
// non-blocking
|
||||
}
|
||||
|
||||
if (disposition === 'APPOINTMENT_BOOKED') {
|
||||
setPostCallStage('appointment');
|
||||
setAppointmentOpen(true);
|
||||
} else if (disposition === 'FOLLOW_UP_SCHEDULED') {
|
||||
setPostCallStage('follow-up');
|
||||
// Create follow-up
|
||||
try {
|
||||
await apiClient.graphql(`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`, {
|
||||
data: {
|
||||
name: `Follow-up — ${fullName || phoneDisplay}`,
|
||||
typeCustom: 'CALLBACK',
|
||||
status: 'PENDING',
|
||||
assignedAgent: null,
|
||||
priority: 'NORMAL',
|
||||
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
}, { silent: true });
|
||||
notify.success('Follow-up Created', 'Callback scheduled for tomorrow');
|
||||
} catch {
|
||||
notify.info('Follow-up', 'Could not auto-create follow-up');
|
||||
}
|
||||
setPostCallStage('done');
|
||||
} else {
|
||||
notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`);
|
||||
setPostCallStage('done');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAppointmentSaved = () => {
|
||||
setAppointmentOpen(false);
|
||||
notify.success('Appointment Booked', 'Payment link will be sent to the patient');
|
||||
setPostCallStage('done');
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPostCallStage(null);
|
||||
setSavedDisposition(null);
|
||||
setCallState('idle');
|
||||
setCallerNumber(null);
|
||||
};
|
||||
|
||||
// Ringing state
|
||||
if (callState === 'ringing-in') {
|
||||
return (
|
||||
<div className="rounded-xl bg-brand-primary p-4">
|
||||
@@ -43,17 +123,14 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" color="primary" onClick={answer}>
|
||||
Answer
|
||||
</Button>
|
||||
<Button size="sm" color="tertiary-destructive" onClick={reject}>
|
||||
Decline
|
||||
</Button>
|
||||
<Button size="sm" color="primary" onClick={answer}>Answer</Button>
|
||||
<Button size="sm" color="tertiary-destructive" onClick={reject}>Decline</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Active call
|
||||
if (callState === 'active') {
|
||||
return (
|
||||
<div className="rounded-xl border border-brand bg-primary p-4">
|
||||
@@ -70,38 +147,88 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
||||
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
color={isMuted ? 'primary-destructive' : 'secondary'}
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className={className} />
|
||||
)}
|
||||
onClick={toggleMute}
|
||||
>
|
||||
{isMuted ? 'Unmute' : 'Mute'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color={isOnHold ? 'primary-destructive' : 'secondary'}
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className={className} />
|
||||
)}
|
||||
onClick={toggleHold}
|
||||
>
|
||||
{isOnHold ? 'Resume' : 'Hold'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary-destructive"
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faPhoneHangup} className={className} />
|
||||
)}
|
||||
onClick={hangup}
|
||||
className="ml-auto"
|
||||
>
|
||||
End
|
||||
<Button size="sm" color={isMuted ? 'primary-destructive' : 'secondary'}
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className={className} />}
|
||||
onClick={toggleMute}>{isMuted ? 'Unmute' : 'Mute'}</Button>
|
||||
<Button size="sm" color={isOnHold ? 'primary-destructive' : 'secondary'}
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className={className} />}
|
||||
onClick={toggleHold}>{isOnHold ? 'Resume' : 'Hold'}</Button>
|
||||
<Button size="sm" color="secondary"
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faCalendarPlus} className={className} />}
|
||||
onClick={() => setAppointmentOpen(true)}>Book Appt</Button>
|
||||
<Button size="sm" color="primary-destructive" className="ml-auto"
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faPhoneHangup} className={className} />}
|
||||
onClick={() => { hangup(); setPostCallStage('disposition'); }}>End</Button>
|
||||
</div>
|
||||
|
||||
{/* Appointment form accessible during call */}
|
||||
<AppointmentForm
|
||||
isOpen={appointmentOpen}
|
||||
onOpenChange={setAppointmentOpen}
|
||||
callerNumber={callerPhone}
|
||||
leadName={fullName || null}
|
||||
leadId={lead?.id ?? null}
|
||||
onSaved={handleAppointmentSaved}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Call ended — show disposition
|
||||
if (callState === 'ended' || callState === 'failed' || postCallStage !== null) {
|
||||
// Done state
|
||||
if (postCallStage === 'done') {
|
||||
return (
|
||||
<div className="rounded-xl border border-success bg-success-primary p-4 text-center">
|
||||
<FontAwesomeIcon icon={faCheckCircle} className="size-8 text-fg-success-primary mb-2" />
|
||||
<p className="text-sm font-semibold text-success-primary">Call Completed</p>
|
||||
<p className="text-xs text-tertiary mt-1">
|
||||
{savedDisposition ? savedDisposition.replace(/_/g, ' ').toLowerCase() : 'logged'}
|
||||
</p>
|
||||
<Button size="sm" color="secondary" className="mt-3" onClick={handleReset}>
|
||||
Back to Worklist
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Appointment booking after disposition
|
||||
if (postCallStage === 'appointment') {
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-xl border border-brand bg-brand-primary p-4 text-center">
|
||||
<FontAwesomeIcon icon={faCalendarPlus} className="size-6 text-fg-brand-primary mb-2" />
|
||||
<p className="text-sm font-semibold text-brand-secondary">Booking Appointment</p>
|
||||
<p className="text-xs text-tertiary mt-1">for {fullName || phoneDisplay}</p>
|
||||
</div>
|
||||
<AppointmentForm
|
||||
isOpen={appointmentOpen}
|
||||
onOpenChange={(open) => {
|
||||
setAppointmentOpen(open);
|
||||
if (!open) setPostCallStage('done');
|
||||
}}
|
||||
callerNumber={callerPhone}
|
||||
leadName={fullName || null}
|
||||
leadId={lead?.id ?? null}
|
||||
onSaved={handleAppointmentSaved}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Disposition form
|
||||
return (
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-secondary">
|
||||
<FontAwesomeIcon icon={faPhoneHangup} className="size-3.5 text-fg-quaternary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-primary">Call Ended — {fullName || phoneDisplay}</p>
|
||||
<p className="text-xs text-tertiary">{formatDuration(callDuration)} · Log this call</p>
|
||||
</div>
|
||||
</div>
|
||||
<DispositionForm onSubmit={handleDisposition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,7 +80,8 @@ export const DashboardKpi = ({ calls, leads }: DashboardKpiProps) => {
|
||||
const leadsWithResponse = leads.filter((l) => l.createdAt && l.firstContactedAt);
|
||||
const avgResponseTime = leadsWithResponse.length > 0
|
||||
? Math.round(leadsWithResponse.reduce((sum, l) => {
|
||||
return sum + (new Date(l.firstContactedAt!).getTime() - new Date(l.createdAt!).getTime()) / 60000;
|
||||
const diff = Math.abs(new Date(l.firstContactedAt!).getTime() - new Date(l.createdAt!).getTime()) / 60000;
|
||||
return sum + diff;
|
||||
}, 0) / leadsWithResponse.length)
|
||||
: null;
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export const CallDeskPage = () => {
|
||||
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
||||
const [contextOpen, setContextOpen] = useState(true);
|
||||
|
||||
const isInCall = callState === 'ringing-in' || callState === 'ringing-out' || callState === 'active';
|
||||
const isInCall = callState === 'ringing-in' || callState === 'ringing-out' || callState === 'active' || callState === 'ended' || callState === 'failed';
|
||||
|
||||
const callerLead = callerNumber
|
||||
? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---'))
|
||||
|
||||
@@ -115,7 +115,7 @@ export const CampaignsPage = () => {
|
||||
leads={leadsByCampaign.get(campaign.id) ?? []}
|
||||
/>
|
||||
</Link>
|
||||
<div className="absolute top-4 right-14">
|
||||
<div className="absolute bottom-4 right-4 z-10">
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
|
||||
@@ -64,16 +64,8 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
|
||||
}
|
||||
}, [callState]);
|
||||
|
||||
// Auto-reset to idle after ended/failed
|
||||
useEffect(() => {
|
||||
if (callState === 'ended' || callState === 'failed') {
|
||||
const timer = setTimeout(() => {
|
||||
setCallState('idle');
|
||||
setCallerNumber(null);
|
||||
}, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [callState, setCallState, setCallerNumber]);
|
||||
// No auto-reset — the ActiveCallCard handles post-call flow (disposition → appointment → done)
|
||||
// and resets to idle via the "Back to Worklist" button
|
||||
|
||||
// Cleanup on page unload
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user