mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
feat: disposition modal, persistent top bar, pagination, QA fixes
- DispositionModal: single modal for all call endings. Dismissable (agent can resume call). Agent clicks End → modal → select reason → hangup + dispose. Caller disconnects → same modal. - One call screen: CallWidget stripped to ringing notification + auto-redirect to Call Desk. - Persistent top bar in AppShell: agent status toggle + network indicator on all pages. - Network indicator always visible (Connected/Unstable/No connection). - Pagination: Untitled UI PaginationCardDefault on Call History + Appointments (20/page). - Pinned table headers/footers: sticky column headers, scrollable body, pinned pagination. Applied to Call Desk worklist, Call History, Appointments, Call Recordings, Missed Calls. - "Patient" → "Caller" column label in Call History. - Offline → Ready toggle enabled. - Profile status dot reflects Ozonetel state. - NavAccountCard: popover placement top, View Profile + Account Settings restored. - WIP pages for /profile and /account-settings. - Enquiry form PHONE_INQUIRY → PHONE enum fix. - Force Ready / View Profile / Account Settings removed then restored properly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,174 +1,41 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
faPhone,
|
||||
faPhoneArrowDown,
|
||||
faPhoneArrowUp,
|
||||
faPhoneHangup,
|
||||
faPhoneXmark,
|
||||
faMicrophoneSlash,
|
||||
faMicrophone,
|
||||
faPause,
|
||||
faCircleCheck,
|
||||
faFloppyDisk,
|
||||
faCalendarPlus,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import { faPhone, faPhoneArrowDown, faPhoneXmark, faCircleCheck } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faIcon } from '@/lib/icon-wrapper';
|
||||
|
||||
const Phone01 = faIcon(faPhone);
|
||||
const PhoneIncoming01 = faIcon(faPhoneArrowDown);
|
||||
const PhoneOutgoing01 = faIcon(faPhoneArrowUp);
|
||||
const PhoneHangUp = faIcon(faPhoneHangup);
|
||||
const PhoneX = faIcon(faPhoneXmark);
|
||||
const MicrophoneOff01 = faIcon(faMicrophoneSlash);
|
||||
const Microphone01 = faIcon(faMicrophone);
|
||||
const PauseCircle = faIcon(faPause);
|
||||
const CheckCircle = faIcon(faCircleCheck);
|
||||
const Save01 = faIcon(faFloppyDisk);
|
||||
const CalendarPlus02 = faIcon(faCalendarPlus);
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { TextArea } from '@/components/base/textarea/textarea';
|
||||
import { AppointmentForm } from '@/components/call-desk/appointment-form';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { sipCallStateAtom } from '@/state/sip-state';
|
||||
import { useSip } from '@/providers/sip-provider';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { CallDisposition } from '@/types/entities';
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||
const s = (seconds % 60).toString().padStart(2, '0');
|
||||
return `${m}:${s}`;
|
||||
};
|
||||
|
||||
const dispositionOptions: Array<{
|
||||
value: CallDisposition;
|
||||
label: string;
|
||||
activeClass: string;
|
||||
defaultClass: string;
|
||||
}> = [
|
||||
{
|
||||
value: 'APPOINTMENT_BOOKED',
|
||||
label: 'Appt 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',
|
||||
activeClass: 'bg-brand-solid text-white ring-transparent',
|
||||
defaultClass: 'bg-brand-primary text-brand-secondary border-brand',
|
||||
},
|
||||
{
|
||||
value: 'INFO_PROVIDED',
|
||||
label: 'Info Given',
|
||||
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 #',
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
// CallWidget is a lightweight floating notification for calls outside the Call Desk.
|
||||
// It only handles: ringing (answer/decline) + auto-redirect to Call Desk.
|
||||
// All active call management (mute, hold, end, disposition) happens on the Call Desk via ActiveCallCard.
|
||||
export const CallWidget = () => {
|
||||
const {
|
||||
callState,
|
||||
callerNumber,
|
||||
isMuted,
|
||||
isOnHold,
|
||||
callDuration,
|
||||
answer,
|
||||
reject,
|
||||
hangup,
|
||||
toggleMute,
|
||||
toggleHold,
|
||||
} = useSip();
|
||||
const { user } = useAuth();
|
||||
const { callState, callerNumber, callDuration, answer, reject } = useSip();
|
||||
const setCallState = useSetAtom(sipCallStateAtom);
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const [disposition, setDisposition] = useState<CallDisposition | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [lastDuration, setLastDuration] = useState(0);
|
||||
const [matchedLead, setMatchedLead] = useState<any>(null);
|
||||
const [leadActivities, setLeadActivities] = useState<any[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isAppointmentOpen, setIsAppointmentOpen] = useState(false);
|
||||
const callStartTimeRef = useRef<string | null>(null);
|
||||
|
||||
// Capture duration right before call ends
|
||||
// Auto-navigate to Call Desk when a call becomes active or outbound ringing starts
|
||||
useEffect(() => {
|
||||
if (callState === 'active' && callDuration > 0) {
|
||||
setLastDuration(callDuration);
|
||||
if (pathname === '/call-desk') return;
|
||||
if (callState === 'active' || callState === 'ringing-out') {
|
||||
console.log(`[CALL-WIDGET] Redirecting to Call Desk (state=${callState})`);
|
||||
navigate('/call-desk');
|
||||
}
|
||||
}, [callState, callDuration]);
|
||||
|
||||
// Track call start time
|
||||
useEffect(() => {
|
||||
if (callState === 'active' && !callStartTimeRef.current) {
|
||||
callStartTimeRef.current = new Date().toISOString();
|
||||
}
|
||||
if (callState === 'idle') {
|
||||
callStartTimeRef.current = null;
|
||||
}
|
||||
}, [callState]);
|
||||
|
||||
// Look up caller when call becomes active
|
||||
useEffect(() => {
|
||||
if (callState === 'ringing-in' && callerNumber && callerNumber !== 'Unknown') {
|
||||
const lookup = async () => {
|
||||
try {
|
||||
const { apiClient } = await import('@/lib/api-client');
|
||||
const token = apiClient.getStoredToken();
|
||||
if (!token) return;
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||
const res = await fetch(`${API_URL}/api/call/lookup`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ phoneNumber: callerNumber }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.matched && data.lead) {
|
||||
setMatchedLead(data.lead);
|
||||
setLeadActivities(data.activities ?? []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Lead lookup failed:', err);
|
||||
}
|
||||
};
|
||||
lookup();
|
||||
}
|
||||
}, [callState, callerNumber]);
|
||||
|
||||
// Reset state when returning to idle
|
||||
useEffect(() => {
|
||||
if (callState === 'idle') {
|
||||
setDisposition(null);
|
||||
setNotes('');
|
||||
setMatchedLead(null);
|
||||
setLeadActivities([]);
|
||||
}
|
||||
}, [callState]);
|
||||
}, [callState, pathname, navigate]);
|
||||
|
||||
// Auto-dismiss ended/failed state after 3 seconds
|
||||
useEffect(() => {
|
||||
@@ -181,127 +48,35 @@ export const CallWidget = () => {
|
||||
}
|
||||
}, [callState, setCallState]);
|
||||
|
||||
const handleSaveAndClose = async () => {
|
||||
if (!disposition) return;
|
||||
console.log(`[CALL-WIDGET] Save & Close: disposition=${disposition} lead=${matchedLead?.id ?? 'none'}`);
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const { apiClient } = await import('@/lib/api-client');
|
||||
|
||||
// 1. Create Call record on platform
|
||||
await apiClient.graphql(
|
||||
`mutation CreateCall($data: CallCreateInput!) {
|
||||
createCall(data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
data: {
|
||||
callDirection: 'INBOUND',
|
||||
callStatus: 'COMPLETED',
|
||||
agentName: user.name,
|
||||
startedAt: callStartTimeRef.current,
|
||||
endedAt: new Date().toISOString(),
|
||||
durationSeconds: callDuration,
|
||||
disposition,
|
||||
callNotes: notes || null,
|
||||
leadId: matchedLead?.id ?? null,
|
||||
},
|
||||
},
|
||||
).catch(err => console.warn('Failed to create call record:', err));
|
||||
|
||||
// 2. Update lead status if matched
|
||||
if (matchedLead?.id) {
|
||||
const statusMap: Partial<Record<string, string>> = {
|
||||
APPOINTMENT_BOOKED: 'APPOINTMENT_SET',
|
||||
FOLLOW_UP_SCHEDULED: 'CONTACTED',
|
||||
INFO_PROVIDED: 'CONTACTED',
|
||||
NO_ANSWER: 'CONTACTED',
|
||||
WRONG_NUMBER: 'LOST',
|
||||
CALLBACK_REQUESTED: 'CONTACTED',
|
||||
NOT_INTERESTED: 'LOST',
|
||||
};
|
||||
const newStatus = statusMap[disposition];
|
||||
if (newStatus) {
|
||||
await apiClient.graphql(
|
||||
`mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) {
|
||||
updateLead(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
id: matchedLead.id,
|
||||
data: {
|
||||
leadStatus: newStatus,
|
||||
lastContactedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
).catch(err => console.warn('Failed to update lead:', err));
|
||||
}
|
||||
|
||||
// 3. Create lead activity
|
||||
await apiClient.graphql(
|
||||
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
|
||||
createLeadActivity(data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
data: {
|
||||
activityType: 'CALL_RECEIVED',
|
||||
summary: `Inbound call — ${disposition.replace(/_/g, ' ')}`,
|
||||
occurredAt: new Date().toISOString(),
|
||||
performedBy: user.name,
|
||||
channel: 'PHONE',
|
||||
durationSeconds: callDuration,
|
||||
leadId: matchedLead.id,
|
||||
},
|
||||
},
|
||||
).catch(err => console.warn('Failed to create activity:', err));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err);
|
||||
}
|
||||
|
||||
setIsSaving(false);
|
||||
hangup();
|
||||
setDisposition(null);
|
||||
setNotes('');
|
||||
};
|
||||
|
||||
// Log state changes for observability
|
||||
// Log state changes
|
||||
useEffect(() => {
|
||||
if (callState !== 'idle') {
|
||||
console.log(`[CALL-WIDGET] State: ${callState} | caller=${callerNumber ?? 'none'}`);
|
||||
}
|
||||
}, [callState, callerNumber]);
|
||||
|
||||
// Idle: nothing to show — call desk has its own status toggle
|
||||
if (callState === 'idle') {
|
||||
return null;
|
||||
}
|
||||
if (callState === 'idle') return null;
|
||||
|
||||
// Ringing inbound
|
||||
// Ringing inbound — answer redirects to Call Desk
|
||||
if (callState === 'ringing-in') {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'fixed bottom-6 right-6 z-50 w-80',
|
||||
'flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl',
|
||||
'transition-all duration-300',
|
||||
)}
|
||||
>
|
||||
<div className={cx(
|
||||
'fixed bottom-6 right-6 z-50 w-80',
|
||||
'flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl',
|
||||
'transition-all duration-300',
|
||||
)}>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 animate-ping rounded-full bg-brand-solid opacity-20" />
|
||||
<div className="relative animate-bounce">
|
||||
<PhoneIncoming01 className="size-10 text-fg-brand-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">
|
||||
Incoming Call
|
||||
</span>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">Incoming Call</span>
|
||||
<span className="text-lg font-bold text-primary">{callerNumber ?? 'Unknown'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="md" color="primary" iconLeading={Phone01} onClick={answer}>
|
||||
<Button size="md" color="primary" iconLeading={Phone01} onClick={() => { answer(); navigate('/call-desk'); }}>
|
||||
Answer
|
||||
</Button>
|
||||
<Button size="md" color="primary-destructive" iconLeading={PhoneX} onClick={reject}>
|
||||
@@ -312,208 +87,25 @@ export const CallWidget = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Ringing outbound
|
||||
if (callState === 'ringing-out') {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'fixed bottom-6 right-6 z-50 w-80',
|
||||
'flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl',
|
||||
'transition-all duration-300',
|
||||
)}
|
||||
>
|
||||
<PhoneOutgoing01 className="size-10 animate-pulse text-fg-brand-primary" />
|
||||
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">
|
||||
Calling...
|
||||
</span>
|
||||
<span className="text-lg font-bold text-primary">{callerNumber ?? 'Unknown'}</span>
|
||||
</div>
|
||||
|
||||
<Button size="md" color="primary-destructive" iconLeading={PhoneHangUp} onClick={hangup}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Active call (full widget)
|
||||
if (callState === 'active') {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'fixed bottom-6 right-6 z-50 w-80',
|
||||
'flex flex-col gap-4 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl',
|
||||
'transition-all duration-300',
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone01 className="size-4 text-fg-success-primary" />
|
||||
<span className="text-sm font-semibold text-primary">Active Call</span>
|
||||
</div>
|
||||
<span className="font-mono text-sm font-bold tabular-nums text-brand-secondary">
|
||||
{formatDuration(callDuration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Caller info */}
|
||||
<div>
|
||||
<span className="text-lg font-bold text-primary">
|
||||
{matchedLead?.contactName
|
||||
? `${matchedLead.contactName.firstName ?? ''} ${matchedLead.contactName.lastName ?? ''}`.trim()
|
||||
: callerNumber ?? 'Unknown'}
|
||||
</span>
|
||||
{matchedLead && (
|
||||
<span className="ml-2 text-sm text-tertiary">{callerNumber}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Summary */}
|
||||
{matchedLead?.aiSummary && (
|
||||
<div className="rounded-xl bg-brand-primary p-3">
|
||||
<div className="mb-1 text-xs font-bold uppercase tracking-wider text-brand-secondary">AI Insight</div>
|
||||
<p className="text-sm text-primary">{matchedLead.aiSummary}</p>
|
||||
{matchedLead.aiSuggestedAction && (
|
||||
<span className="mt-2 inline-block rounded-lg bg-brand-solid px-3 py-1 text-xs font-semibold text-white">
|
||||
{matchedLead.aiSuggestedAction}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent activity */}
|
||||
{leadActivities.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-semibold text-tertiary">Recent Activity</div>
|
||||
{leadActivities.slice(0, 3).map((a: any, i: number) => (
|
||||
<div key={i} className="text-xs text-quaternary">
|
||||
{a.activityType?.replace(/_/g, ' ')}: {a.summary}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Call controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
color={isMuted ? 'primary' : 'secondary'}
|
||||
iconLeading={isMuted ? MicrophoneOff01 : Microphone01}
|
||||
onClick={toggleMute}
|
||||
>
|
||||
{isMuted ? 'Unmute' : 'Mute'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color={isOnHold ? 'primary' : 'secondary'}
|
||||
iconLeading={PauseCircle}
|
||||
onClick={toggleHold}
|
||||
>
|
||||
{isOnHold ? 'Resume' : 'Hold'}
|
||||
</Button>
|
||||
<Button size="sm" color="primary-destructive" iconLeading={PhoneHangUp} onClick={hangup}>
|
||||
End
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Book Appointment */}
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
iconLeading={CalendarPlus02}
|
||||
onClick={() => setIsAppointmentOpen(true)}
|
||||
className="w-full"
|
||||
>
|
||||
Book Appointment
|
||||
</Button>
|
||||
|
||||
<AppointmentForm
|
||||
isOpen={isAppointmentOpen}
|
||||
onOpenChange={setIsAppointmentOpen}
|
||||
callerNumber={callerNumber}
|
||||
leadName={matchedLead ? `${matchedLead.contactName?.firstName ?? ''} ${matchedLead.contactName?.lastName ?? ''}`.trim() : null}
|
||||
leadId={matchedLead?.id}
|
||||
patientId={matchedLead?.patientId}
|
||||
onSaved={() => {
|
||||
setIsAppointmentOpen(false);
|
||||
setDisposition('APPOINTMENT_BOOKED');
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-secondary" />
|
||||
|
||||
{/* Disposition */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-secondary">Disposition</span>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{dispositionOptions.map((opt) => {
|
||||
const isSelected = disposition === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setDisposition(opt.value)}
|
||||
className={cx(
|
||||
'cursor-pointer rounded-lg border px-2.5 py-1.5 text-xs font-semibold transition duration-100 ease-linear',
|
||||
isSelected ? cx(opt.activeClass, 'ring-2 ring-brand') : opt.defaultClass,
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
placeholder="Add notes..."
|
||||
value={notes}
|
||||
onChange={(value) => setNotes(value)}
|
||||
rows={2}
|
||||
textAreaClassName="text-xs"
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
iconLeading={Save01}
|
||||
isDisabled={disposition === null || isSaving}
|
||||
isLoading={isSaving}
|
||||
onClick={handleSaveAndClose}
|
||||
className="w-full"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save & Close'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Ended / Failed
|
||||
// Ended / Failed — brief notification
|
||||
if (callState === 'ended' || callState === 'failed') {
|
||||
const isEnded = callState === 'ended';
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'fixed bottom-6 right-6 z-50 w-80',
|
||||
'flex flex-col items-center gap-2 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl',
|
||||
'transition-all duration-300',
|
||||
)}
|
||||
>
|
||||
<CheckCircle
|
||||
className={cx('size-8', isEnded ? 'text-fg-success-primary' : 'text-fg-error-primary')}
|
||||
/>
|
||||
<div className={cx(
|
||||
'fixed bottom-6 right-6 z-50 w-80',
|
||||
'flex flex-col items-center gap-2 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl',
|
||||
'transition-all duration-300',
|
||||
)}>
|
||||
<CheckCircle className={cx('size-8', isEnded ? 'text-fg-success-primary' : 'text-fg-error-primary')} />
|
||||
<span className="text-sm font-semibold text-primary">
|
||||
{isEnded ? 'Call Ended' : 'Call Failed'}
|
||||
{lastDuration > 0 && ` \u00B7 ${formatDuration(lastDuration)}`}
|
||||
{callDuration > 0 && ` \u00B7 ${formatDuration(callDuration)}`}
|
||||
</span>
|
||||
<span className="text-xs text-tertiary">auto-closing...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Any other state (active, ringing-out) on a non-call-desk page — redirect handled by useEffect above
|
||||
return null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user