mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
feat: build CC Agent Call Desk with CTI simulation, AI insights, disposition form, and call log
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
77
src/components/call-desk/call-log.tsx
Normal file
77
src/components/call-desk/call-log.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { ArrowRight } from '@untitledui/icons';
|
||||
import { formatShortDate } from '@/lib/format';
|
||||
import type { Call, CallDisposition } from '@/types/entities';
|
||||
|
||||
interface CallLogProps {
|
||||
calls: Call[];
|
||||
}
|
||||
|
||||
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
||||
APPOINTMENT_BOOKED: { label: 'Booked', color: 'success' },
|
||||
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
|
||||
INFO_PROVIDED: { label: 'Info', color: 'blue-light' },
|
||||
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
||||
WRONG_NUMBER: { label: 'Wrong #', color: 'gray' },
|
||||
CALLBACK_REQUESTED: { label: 'Not Interested', color: 'error' },
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number | null): string => {
|
||||
if (seconds === null || seconds === 0) return '0 min';
|
||||
const minutes = Math.round(seconds / 60);
|
||||
return `${minutes} min`;
|
||||
};
|
||||
|
||||
export const CallLog = ({ calls }: CallLogProps) => {
|
||||
return (
|
||||
<div className="rounded-2xl border border-secondary bg-primary">
|
||||
<div className="flex items-center justify-between border-b border-secondary px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-primary">Today's Calls</span>
|
||||
<Badge size="sm" color="gray">{calls.length}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{calls.length > 0 ? (
|
||||
<div className="divide-y divide-secondary">
|
||||
{calls.map((call) => {
|
||||
const config = call.disposition !== null
|
||||
? dispositionConfig[call.disposition]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={call.id}
|
||||
className="flex items-center gap-3 px-5 py-3"
|
||||
>
|
||||
<span className="w-20 shrink-0 text-xs text-quaternary">
|
||||
{call.startedAt !== null ? formatShortDate(call.startedAt) : '—'}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-primary">
|
||||
{call.leadName ?? call.callerNumber?.[0]?.number ?? 'Unknown'}
|
||||
</span>
|
||||
{config !== null && (
|
||||
<Badge size="sm" color={config.color}>{config.label}</Badge>
|
||||
)}
|
||||
<span className="w-12 shrink-0 text-right text-xs text-quaternary">
|
||||
{formatDuration(call.durationSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-sm text-quaternary">No calls handled today</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-secondary px-5 py-3">
|
||||
<Button href="/call-history" color="link-color" size="sm" iconTrailing={ArrowRight}>
|
||||
View Full History
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
26
src/components/call-desk/call-simulator.tsx
Normal file
26
src/components/call-desk/call-simulator.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { PhoneCall01 } from '@untitledui/icons';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
interface CallSimulatorProps {
|
||||
onSimulate: () => void;
|
||||
isCallActive: boolean;
|
||||
}
|
||||
|
||||
export const CallSimulator = ({ onSimulate, isCallActive }: CallSimulatorProps) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSimulate}
|
||||
disabled={isCallActive}
|
||||
className={cx(
|
||||
'inline-flex w-full items-center justify-center gap-2 rounded-xl px-6 py-3 text-sm font-semibold text-white transition duration-100 ease-linear sm:w-auto',
|
||||
isCallActive
|
||||
? 'cursor-not-allowed bg-disabled text-disabled'
|
||||
: 'cursor-pointer bg-brand-solid hover:bg-brand-solid_hover [&:hover_svg]:animate-[ring-shake_0.5s_ease-in-out]',
|
||||
)}
|
||||
>
|
||||
<PhoneCall01 className="size-5 shrink-0" />
|
||||
{isCallActive ? 'Call in progress...' : 'Simulate Incoming Call'}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
43
src/components/call-desk/daily-stats.tsx
Normal file
43
src/components/call-desk/daily-stats.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Call } from '@/types/entities';
|
||||
|
||||
interface DailyStatsProps {
|
||||
calls: Call[];
|
||||
}
|
||||
|
||||
const formatAvgDuration = (calls: Call[]): string => {
|
||||
if (calls.length === 0) return '0.0 min';
|
||||
const totalSeconds = calls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0);
|
||||
const avgMinutes = totalSeconds / calls.length / 60;
|
||||
return `${avgMinutes.toFixed(1)} min`;
|
||||
};
|
||||
|
||||
export const DailyStats = ({ calls }: DailyStatsProps) => {
|
||||
const callsHandled = calls.length;
|
||||
const appointmentsBooked = calls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||
const followUps = calls.filter((c) => c.disposition === 'FOLLOW_UP_SCHEDULED').length;
|
||||
const avgDuration = formatAvgDuration(calls);
|
||||
|
||||
const stats = [
|
||||
{ label: 'Calls Handled', value: String(callsHandled) },
|
||||
{ label: 'Appointments Booked', value: String(appointmentsBooked) },
|
||||
{ label: 'Follow-ups', value: String(followUps) },
|
||||
{ label: 'Avg Duration', value: avgDuration },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-bold text-primary">Daily Stats</h3>
|
||||
{stats.map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="rounded-xl bg-secondary p-4 text-center"
|
||||
>
|
||||
<div className="text-display-xs font-bold text-primary">{stat.value}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-wider text-tertiary">
|
||||
{stat.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
111
src/components/call-desk/disposition-form.tsx
Normal file
111
src/components/call-desk/disposition-form.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react';
|
||||
import { TextArea } from '@/components/base/textarea/textarea';
|
||||
import type { CallDisposition } from '@/types/entities';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
interface DispositionFormProps {
|
||||
onSubmit: (disposition: CallDisposition, notes: string) => void;
|
||||
}
|
||||
|
||||
const dispositionOptions: Array<{
|
||||
value: CallDisposition;
|
||||
label: string;
|
||||
activeClass: string;
|
||||
defaultClass: string;
|
||||
}> = [
|
||||
{
|
||||
value: 'APPOINTMENT_BOOKED',
|
||||
label: 'Appointment Booked',
|
||||
activeClass: 'bg-success-solid text-white ring-transparent',
|
||||
defaultClass: 'bg-success-primary text-success-primary border-success',
|
||||
},
|
||||
{
|
||||
value: 'FOLLOW_UP_SCHEDULED',
|
||||
label: 'Follow-up Needed',
|
||||
activeClass: 'bg-brand-solid text-white ring-transparent',
|
||||
defaultClass: 'bg-brand-primary text-brand-secondary border-brand',
|
||||
},
|
||||
{
|
||||
value: 'INFO_PROVIDED',
|
||||
label: 'Info Provided',
|
||||
activeClass: 'bg-utility-blue-light-600 text-white ring-transparent',
|
||||
defaultClass: 'bg-utility-blue-light-50 text-utility-blue-light-700 border-utility-blue-light-200',
|
||||
},
|
||||
{
|
||||
value: 'NO_ANSWER',
|
||||
label: 'No Answer',
|
||||
activeClass: 'bg-warning-solid text-white ring-transparent',
|
||||
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
|
||||
},
|
||||
{
|
||||
value: 'WRONG_NUMBER',
|
||||
label: 'Wrong Number',
|
||||
activeClass: 'bg-secondary-solid text-white ring-transparent',
|
||||
defaultClass: 'bg-secondary text-secondary border-secondary',
|
||||
},
|
||||
{
|
||||
value: 'CALLBACK_REQUESTED',
|
||||
label: 'Not Interested',
|
||||
activeClass: 'bg-error-solid text-white ring-transparent',
|
||||
defaultClass: 'bg-error-primary text-error-primary border-error',
|
||||
},
|
||||
];
|
||||
|
||||
export const DispositionForm = ({ onSubmit }: DispositionFormProps) => {
|
||||
const [selected, setSelected] = useState<CallDisposition | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (selected === null) return;
|
||||
onSubmit(selected, notes);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="text-sm font-bold text-primary">What happened?</span>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{dispositionOptions.map((option) => {
|
||||
const isSelected = selected === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setSelected(option.value)}
|
||||
className={cx(
|
||||
'cursor-pointer rounded-xl border-2 p-3 text-xs font-semibold transition duration-100 ease-linear',
|
||||
isSelected
|
||||
? cx(option.activeClass, 'ring-2 ring-brand')
|
||||
: option.defaultClass,
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
label="Notes (optional)"
|
||||
placeholder="Add any notes about this call..."
|
||||
value={notes}
|
||||
onChange={(value) => setNotes(value)}
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={selected === null}
|
||||
className={cx(
|
||||
'w-full rounded-xl py-3 text-sm font-semibold transition duration-100 ease-linear',
|
||||
selected !== null
|
||||
? 'cursor-pointer bg-brand-solid text-white hover:bg-brand-solid_hover'
|
||||
: 'cursor-not-allowed bg-disabled text-disabled',
|
||||
)}
|
||||
>
|
||||
Save & Close Call
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
263
src/components/call-desk/incoming-call-card.tsx
Normal file
263
src/components/call-desk/incoming-call-card.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Phone01, PhoneIncoming01, CheckCircle, Mail01, Clock, Stars02 } from '@untitledui/icons';
|
||||
import { Avatar } from '@/components/base/avatar/avatar';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { SourceTag } from '@/components/shared/source-tag';
|
||||
import { AgeIndicator } from '@/components/shared/age-indicator';
|
||||
import { DispositionForm } from './disposition-form';
|
||||
import { formatPhone, formatShortDate, getInitials } from '@/lib/format';
|
||||
import type { Lead, LeadActivity, CallDisposition, Campaign } from '@/types/entities';
|
||||
|
||||
type CallState = 'idle' | 'ringing' | 'active' | 'completed';
|
||||
|
||||
interface IncomingCallCardProps {
|
||||
callState: CallState;
|
||||
lead: Lead | null;
|
||||
activities: LeadActivity[];
|
||||
campaigns: Campaign[];
|
||||
onDisposition: (disposition: CallDisposition, notes: string) => void;
|
||||
completedDisposition?: CallDisposition | null;
|
||||
}
|
||||
|
||||
const activityTypeIcons: Record<string, string> = {
|
||||
CALL_MADE: 'phone',
|
||||
CALL_RECEIVED: 'phone',
|
||||
WHATSAPP_SENT: 'message',
|
||||
WHATSAPP_RECEIVED: 'message',
|
||||
SMS_SENT: 'message',
|
||||
EMAIL_SENT: 'email',
|
||||
EMAIL_RECEIVED: 'email',
|
||||
NOTE_ADDED: 'note',
|
||||
ASSIGNED: 'assign',
|
||||
STATUS_CHANGE: 'status',
|
||||
APPOINTMENT_BOOKED: 'calendar',
|
||||
FOLLOW_UP_CREATED: 'clock',
|
||||
CONVERTED: 'check',
|
||||
MARKED_SPAM: 'alert',
|
||||
DUPLICATE_DETECTED: 'alert',
|
||||
};
|
||||
|
||||
const ActivityIcon = ({ type }: { type: string }) => {
|
||||
const iconType = activityTypeIcons[type] ?? 'note';
|
||||
const baseClass = 'size-3.5 shrink-0 text-fg-quaternary';
|
||||
|
||||
if (iconType === 'phone') return <Phone01 className={baseClass} />;
|
||||
if (iconType === 'email') return <Mail01 className={baseClass} />;
|
||||
if (iconType === 'clock') return <Clock className={baseClass} />;
|
||||
if (iconType === 'check') return <CheckCircle className={baseClass} />;
|
||||
return <Clock className={baseClass} />;
|
||||
};
|
||||
|
||||
const dispositionLabels: Record<CallDisposition, string> = {
|
||||
APPOINTMENT_BOOKED: 'Appointment Booked',
|
||||
FOLLOW_UP_SCHEDULED: 'Follow-up Needed',
|
||||
INFO_PROVIDED: 'Info Provided',
|
||||
NO_ANSWER: 'No Answer',
|
||||
WRONG_NUMBER: 'Wrong Number',
|
||||
CALLBACK_REQUESTED: 'Not Interested',
|
||||
};
|
||||
|
||||
export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => {
|
||||
if (callState === 'idle') {
|
||||
return <IdleState />;
|
||||
}
|
||||
|
||||
if (callState === 'ringing') {
|
||||
return <RingingState lead={lead} />;
|
||||
}
|
||||
|
||||
if (callState === 'active' && lead !== null) {
|
||||
return <ActiveState lead={lead} activities={activities} campaigns={campaigns} onDisposition={onDisposition} />;
|
||||
}
|
||||
|
||||
if (callState === 'completed') {
|
||||
return <CompletedState disposition={completedDisposition ?? null} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const IdleState = () => (
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border border-secondary bg-secondary p-12 text-center">
|
||||
<div className="mb-4 animate-pulse">
|
||||
<Phone01 className="size-12 text-fg-quaternary" />
|
||||
</div>
|
||||
<p className="text-lg text-tertiary">Waiting for incoming call...</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const RingingState = ({ lead }: { lead: Lead | null }) => {
|
||||
const phoneDisplay = lead?.contactPhone?.[0]
|
||||
? formatPhone(lead.contactPhone[0])
|
||||
: '+91 98765 43210';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl bg-brand-primary p-12 text-center">
|
||||
<div className="relative mb-4">
|
||||
<div className="absolute inset-0 animate-ping rounded-full bg-brand-solid opacity-20" />
|
||||
<div className="relative animate-bounce">
|
||||
<PhoneIncoming01 className="size-12 text-fg-brand-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="mb-1 text-xs font-bold uppercase tracking-wider text-brand-secondary">
|
||||
Incoming Call
|
||||
</span>
|
||||
<span className="text-display-xs font-bold text-primary">
|
||||
{phoneDisplay}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ActiveState = ({
|
||||
lead,
|
||||
activities,
|
||||
campaigns,
|
||||
onDisposition,
|
||||
}: {
|
||||
lead: Lead;
|
||||
activities: LeadActivity[];
|
||||
campaigns: Campaign[];
|
||||
onDisposition: (disposition: CallDisposition, notes: string) => void;
|
||||
}) => {
|
||||
const leadActivities = useMemo(
|
||||
() =>
|
||||
activities
|
||||
.filter((a) => a.leadId === lead.id)
|
||||
.sort((a, b) => {
|
||||
const dateA = a.occurredAt ?? a.createdAt ?? '';
|
||||
const dateB = b.occurredAt ?? b.createdAt ?? '';
|
||||
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||
})
|
||||
.slice(0, 3),
|
||||
[activities, lead.id],
|
||||
);
|
||||
|
||||
const campaignName = useMemo(() => {
|
||||
if (lead.campaignId === null) return null;
|
||||
const campaign = campaigns.find((c) => c.id === lead.campaignId);
|
||||
return campaign?.campaignName ?? null;
|
||||
}, [campaigns, lead.campaignId]);
|
||||
|
||||
const firstName = lead.contactName?.firstName ?? '';
|
||||
const lastName = lead.contactName?.lastName ?? '';
|
||||
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Lead';
|
||||
const initials = firstName && lastName ? getInitials(firstName, lastName) : 'UL';
|
||||
const phoneDisplay = lead.contactPhone?.[0]
|
||||
? formatPhone(lead.contactPhone[0])
|
||||
: 'No phone';
|
||||
const emailDisplay = lead.contactEmail?.[0]?.address ?? null;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-secondary bg-primary shadow-lg">
|
||||
<div className="flex flex-col gap-6 p-6 lg:flex-row">
|
||||
{/* Left section: lead details */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start gap-4">
|
||||
<Avatar size="xl" initials={initials} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-lg font-bold text-primary">{fullName}</h3>
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
<Phone01 className="size-3.5 shrink-0 text-fg-quaternary" />
|
||||
<span className="text-md text-secondary">{phoneDisplay}</span>
|
||||
</div>
|
||||
{emailDisplay !== null && (
|
||||
<div className="mt-0.5 flex items-center gap-1.5">
|
||||
<Mail01 className="size-3.5 shrink-0 text-fg-quaternary" />
|
||||
<span className="text-sm text-tertiary">{emailDisplay}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{lead.leadSource !== null && (
|
||||
<SourceTag source={lead.leadSource} size="sm" />
|
||||
)}
|
||||
{campaignName !== null && (
|
||||
<Badge size="sm" color="brand">{campaignName}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{lead.interestedService !== null && (
|
||||
<p className="mt-1.5 text-sm text-secondary">
|
||||
Interested in: {lead.interestedService}
|
||||
</p>
|
||||
)}
|
||||
{lead.createdAt !== null && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-sm text-tertiary">
|
||||
<span>Lead age:</span>
|
||||
<AgeIndicator dateStr={lead.createdAt} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Insight panel */}
|
||||
<div className="mt-4 rounded-xl bg-brand-primary p-4">
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<Stars02 className="size-4 text-fg-brand-primary" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">
|
||||
AI Insight
|
||||
</span>
|
||||
</div>
|
||||
{lead.aiSummary !== null ? (
|
||||
<>
|
||||
<p className="text-sm text-primary">{lead.aiSummary}</p>
|
||||
{lead.aiSuggestedAction !== null && (
|
||||
<span className="mt-2 inline-block rounded-lg bg-brand-solid px-3 py-1.5 text-xs font-semibold text-white">
|
||||
{lead.aiSuggestedAction}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-quaternary">
|
||||
No AI insights available for this lead
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Previous interactions */}
|
||||
<div className="mt-4">
|
||||
<h4 className="mb-2 text-sm font-bold text-primary">Recent Activity</h4>
|
||||
{leadActivities.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{leadActivities.map((activity) => (
|
||||
<div key={activity.id} className="flex items-start gap-2">
|
||||
<ActivityIcon type={activity.activityType ?? 'NOTE_ADDED'} />
|
||||
<span className="flex-1 text-xs text-secondary">
|
||||
{activity.summary}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-quaternary">
|
||||
{activity.occurredAt !== null
|
||||
? formatShortDate(activity.occurredAt)
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-quaternary">No previous interactions</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right section: disposition form */}
|
||||
<div className="w-full shrink-0 border-t border-secondary pt-4 lg:w-72 lg:border-l lg:border-t-0 lg:pl-6 lg:pt-0">
|
||||
<DispositionForm onSubmit={onDisposition} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CompletedState = ({ disposition }: { disposition: CallDisposition | null }) => {
|
||||
const label = disposition !== null ? dispositionLabels[disposition] : 'Unknown';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl bg-success-primary p-8 text-center">
|
||||
<CheckCircle className="mb-3 size-12 text-fg-success-primary" />
|
||||
<h3 className="text-lg font-bold text-success-primary">Call Logged</h3>
|
||||
{disposition !== null && (
|
||||
<Badge size="md" color="success" className="mt-2">{label}</Badge>
|
||||
)}
|
||||
<p className="mt-2 text-sm text-tertiary">Returning to call desk...</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +1,148 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
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 { CallLog } from '@/components/call-desk/call-log';
|
||||
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';
|
||||
|
||||
type CallState = 'idle' | 'ringing' | 'active' | 'completed';
|
||||
|
||||
const isToday = (dateStr: string): boolean => {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
return (
|
||||
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 = () => {
|
||||
const { user } = useAuth();
|
||||
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);
|
||||
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);
|
||||
|
||||
const todaysCalls = calls.filter(
|
||||
(c) => c.agentName === user.name && c.startedAt !== null && isToday(c.startedAt),
|
||||
);
|
||||
|
||||
const handleSimulateCall = useCallback(() => {
|
||||
if (callState !== '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)];
|
||||
setActiveLead(randomLead);
|
||||
setCallState('ringing');
|
||||
setCompletedDisposition(null);
|
||||
|
||||
ringingTimerRef.current = setTimeout(() => {
|
||||
setCallState('active');
|
||||
callStartRef.current = new Date();
|
||||
}, 1500);
|
||||
}, [callState, leads]);
|
||||
|
||||
const handleDisposition = useCallback(
|
||||
(disposition: CallDisposition, notes: string) => {
|
||||
if (activeLead === 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: activeLead.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: activeLead.id,
|
||||
leadName:
|
||||
`${activeLead.contactName?.firstName ?? ''} ${activeLead.contactName?.lastName ?? ''}`.trim() ||
|
||||
'Unknown',
|
||||
leadPhone: activeLead.contactPhone?.[0]?.number ?? undefined,
|
||||
leadService: activeLead.interestedService ?? undefined,
|
||||
};
|
||||
|
||||
addCall(newCall);
|
||||
|
||||
const newStatus = dispositionToStatus[disposition];
|
||||
if (newStatus !== undefined) {
|
||||
updateLead(activeLead.id, {
|
||||
leadStatus: newStatus,
|
||||
lastContactedAt: now.toISOString(),
|
||||
contactAttempts: (activeLead.contactAttempts ?? 0) + 1,
|
||||
});
|
||||
}
|
||||
|
||||
setCompletedDisposition(disposition);
|
||||
setCallState('completed');
|
||||
|
||||
completedTimerRef.current = setTimeout(() => {
|
||||
setCallState('idle');
|
||||
setActiveLead(null);
|
||||
setCompletedDisposition(null);
|
||||
}, 3000);
|
||||
},
|
||||
[activeLead, user.name, addCall, updateLead],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<TopBar title="Call Desk" subtitle="Manage inbound and outbound calls" />
|
||||
<div className="flex flex-1 items-center justify-center p-7">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<h2 className="text-display-xs font-bold text-primary">Call Desk</h2>
|
||||
<p className="text-sm text-tertiary">Coming soon — call queue, dialer, and live call management.</p>
|
||||
<TopBar title="Call Desk" subtitle={`${user.name} \u00B7 Ramaiah Memorial 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'}
|
||||
/>
|
||||
<IncomingCallCard
|
||||
callState={callState}
|
||||
lead={activeLead}
|
||||
activities={leadActivities}
|
||||
campaigns={campaigns}
|
||||
onDisposition={handleDisposition}
|
||||
completedDisposition={completedDisposition}
|
||||
/>
|
||||
<CallLog calls={todaysCalls} />
|
||||
</div>
|
||||
|
||||
<aside className="hidden w-72 space-y-5 overflow-y-auto border-l border-secondary bg-primary p-5 xl:block">
|
||||
<DailyStats calls={todaysCalls} />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user