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:
2026-03-19 17:24:46 +05:30
parent f341433c8f
commit edd2aa689d
5 changed files with 171 additions and 51 deletions

View File

@@ -1,10 +1,22 @@
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 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 { Button } from '@/components/base/buttons/button';
import { Badge } from '@/components/base/badges/badges'; 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 { useSip } from '@/providers/sip-provider';
import { DispositionForm } from './disposition-form';
import { AppointmentForm } from './appointment-form';
import { formatPhone } from '@/lib/format'; 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 { interface ActiveCallCardProps {
lead: Lead | null; lead: Lead | null;
@@ -19,6 +31,11 @@ const formatDuration = (seconds: number): string => {
export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => { export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
const { callState, callDuration, isMuted, isOnHold, answer, reject, hangup, toggleMute, toggleHold } = useSip(); 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 firstName = lead?.contactName?.firstName ?? '';
const lastName = lead?.contactName?.lastName ?? ''; const lastName = lead?.contactName?.lastName ?? '';
@@ -26,6 +43,69 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
const phone = lead?.contactPhone?.[0]; const phone = lead?.contactPhone?.[0];
const phoneDisplay = phone ? formatPhone(phone) : callerPhone || 'Unknown'; 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') { if (callState === 'ringing-in') {
return ( return (
<div className="rounded-xl bg-brand-primary p-4"> <div className="rounded-xl bg-brand-primary p-4">
@@ -43,17 +123,14 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
</div> </div>
</div> </div>
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
<Button size="sm" color="primary" onClick={answer}> <Button size="sm" color="primary" onClick={answer}>Answer</Button>
Answer <Button size="sm" color="tertiary-destructive" onClick={reject}>Decline</Button>
</Button>
<Button size="sm" color="tertiary-destructive" onClick={reject}>
Decline
</Button>
</div> </div>
</div> </div>
); );
} }
// Active call
if (callState === 'active') { if (callState === 'active') {
return ( return (
<div className="rounded-xl border border-brand bg-primary p-4"> <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> <Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
</div> </div>
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
<Button <Button size="sm" color={isMuted ? 'primary-destructive' : 'secondary'}
size="sm" iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className={className} />}
color={isMuted ? 'primary-destructive' : 'secondary'} onClick={toggleMute}>{isMuted ? 'Unmute' : 'Mute'}</Button>
iconLeading={({ className }: { className?: string }) => ( <Button size="sm" color={isOnHold ? 'primary-destructive' : 'secondary'}
<FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className={className} /> iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className={className} />}
)} onClick={toggleHold}>{isOnHold ? 'Resume' : 'Hold'}</Button>
onClick={toggleMute} <Button size="sm" color="secondary"
> iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faCalendarPlus} className={className} />}
{isMuted ? 'Unmute' : 'Mute'} onClick={() => setAppointmentOpen(true)}>Book Appt</Button>
</Button> <Button size="sm" color="primary-destructive" className="ml-auto"
<Button iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faPhoneHangup} className={className} />}
size="sm" onClick={() => { hangup(); setPostCallStage('disposition'); }}>End</Button>
color={isOnHold ? 'primary-destructive' : 'secondary'} </div>
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className={className} /> {/* Appointment form accessible during call */}
)} <AppointmentForm
onClick={toggleHold} isOpen={appointmentOpen}
> onOpenChange={setAppointmentOpen}
{isOnHold ? 'Resume' : 'Hold'} callerNumber={callerPhone}
</Button> leadName={fullName || null}
<Button leadId={lead?.id ?? null}
size="sm" onSaved={handleAppointmentSaved}
color="primary-destructive" />
iconLeading={({ className }: { className?: string }) => ( </div>
<FontAwesomeIcon icon={faPhoneHangup} className={className} /> );
)} }
onClick={hangup}
className="ml-auto" // Call ended — show disposition
> if (callState === 'ended' || callState === 'failed' || postCallStage !== null) {
End // 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> </Button>
</div> </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> </div>
); );
} }

View File

@@ -80,7 +80,8 @@ export const DashboardKpi = ({ calls, leads }: DashboardKpiProps) => {
const leadsWithResponse = leads.filter((l) => l.createdAt && l.firstContactedAt); const leadsWithResponse = leads.filter((l) => l.createdAt && l.firstContactedAt);
const avgResponseTime = leadsWithResponse.length > 0 const avgResponseTime = leadsWithResponse.length > 0
? Math.round(leadsWithResponse.reduce((sum, l) => { ? 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) }, 0) / leadsWithResponse.length)
: null; : null;

View File

@@ -21,7 +21,7 @@ export const CallDeskPage = () => {
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null); const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
const [contextOpen, setContextOpen] = useState(true); 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 const callerLead = callerNumber
? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---')) ? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---'))

View File

@@ -115,7 +115,7 @@ export const CampaignsPage = () => {
leads={leadsByCampaign.get(campaign.id) ?? []} leads={leadsByCampaign.get(campaign.id) ?? []}
/> />
</Link> </Link>
<div className="absolute top-4 right-14"> <div className="absolute bottom-4 right-4 z-10">
<Button <Button
size="sm" size="sm"
color="secondary" color="secondary"

View File

@@ -64,16 +64,8 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
} }
}, [callState]); }, [callState]);
// Auto-reset to idle after ended/failed // No auto-reset — the ActiveCallCard handles post-call flow (disposition → appointment → done)
useEffect(() => { // and resets to idle via the "Back to Worklist" button
if (callState === 'ended' || callState === 'failed') {
const timer = setTimeout(() => {
setCallState('idle');
setCallerNumber(null);
}, 2000);
return () => clearTimeout(timer);
}
}, [callState, setCallState, setCallerNumber]);
// Cleanup on page unload // Cleanup on page unload
useEffect(() => { useEffect(() => {