mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
feat: SSE agent state, UCID fix, maint module, QA bug fixes
- Fix outbound disposition: store UCID from dial API response (root cause of silent disposition failure) - SSE agent state: real-time Ozonetel state drives status toggle (ready/break/calling/in-call/acw) - Maint module with OTP-protected endpoints (force-ready, unlock-agent, backfill, fix-timestamps) - Maint OTP modal with PinInput component, keyboard shortcuts (Ctrl+Shift+R/U/B/T) - Force-logout via SSE: admin unlock pushes force-logout to connected browsers - Silence JsSIP debug flood, add structured lifecycle logging ([SIP], [DIAL], [DISPOSE], [AGENT-STATE]) - Centralize date formatting with IST-aware formatters across 11 files - Fix call history: non-overlapping aggregates (completed/missed), correct timestamp display - Auto-dismiss CallWidget ended/failed state after 3 seconds - Remove floating "Helix Phone" idle badge from all pages - Fix dead code in agent-state endpoint (auto-assign was unreachable after return) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import {
|
||||||
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
|
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
|
||||||
@@ -52,6 +52,11 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
// Track if the call was ever answered (reached 'active' state)
|
// Track if the call was ever answered (reached 'active' state)
|
||||||
const wasAnsweredRef = useRef(callState === 'active');
|
const wasAnsweredRef = useRef(callState === 'active');
|
||||||
|
|
||||||
|
// Log mount so we can tell which component handled the call
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`);
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const firstName = lead?.contactName?.firstName ?? '';
|
const firstName = lead?.contactName?.firstName ?? '';
|
||||||
const lastName = lead?.contactName?.lastName ?? '';
|
const lastName = lead?.contactName?.lastName ?? '';
|
||||||
const fullName = `${firstName} ${lastName}`.trim();
|
const fullName = `${firstName} ${lastName}`.trim();
|
||||||
@@ -62,7 +67,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
|
|
||||||
// Submit disposition to sidecar — handles Ozonetel ACW release
|
// Submit disposition to sidecar — handles Ozonetel ACW release
|
||||||
if (callUcid) {
|
if (callUcid) {
|
||||||
apiClient.post('/api/ozonetel/dispose', {
|
const disposePayload = {
|
||||||
ucid: callUcid,
|
ucid: callUcid,
|
||||||
disposition,
|
disposition,
|
||||||
callerPhone,
|
callerPhone,
|
||||||
@@ -71,7 +76,13 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
leadId: lead?.id ?? null,
|
leadId: lead?.id ?? null,
|
||||||
notes,
|
notes,
|
||||||
missedCallId: missedCallId ?? undefined,
|
missedCallId: missedCallId ?? undefined,
|
||||||
}).catch((err) => console.warn('Disposition failed:', err));
|
};
|
||||||
|
console.log('[DISPOSE] Sending disposition:', JSON.stringify(disposePayload));
|
||||||
|
apiClient.post('/api/ozonetel/dispose', disposePayload)
|
||||||
|
.then((res) => console.log('[DISPOSE] Response:', JSON.stringify(res)))
|
||||||
|
.catch((err) => console.error('[DISPOSE] Failed:', err));
|
||||||
|
} else {
|
||||||
|
console.warn('[DISPOSE] No callUcid — skipping disposition');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Side effects per disposition type
|
// Side effects per disposition type
|
||||||
|
|||||||
@@ -1,48 +1,62 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faCircle, faChevronDown } from '@fortawesome/pro-duotone-svg-icons';
|
import { faCircle, faChevronDown } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { useAgentState } from '@/hooks/use-agent-state';
|
||||||
|
import type { OzonetelState } from '@/hooks/use-agent-state';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
type AgentStatus = 'ready' | 'break' | 'training' | 'offline';
|
type ToggleableStatus = 'ready' | 'break' | 'training';
|
||||||
|
|
||||||
const statusConfig: Record<AgentStatus, { label: string; color: string; dotColor: string }> = {
|
const displayConfig: Record<OzonetelState, { label: string; color: string; dotColor: string }> = {
|
||||||
ready: { label: 'Ready', color: 'text-success-primary', dotColor: 'text-fg-success-primary' },
|
ready: { label: 'Ready', color: 'text-success-primary', dotColor: 'text-fg-success-primary' },
|
||||||
break: { label: 'Break', color: 'text-warning-primary', dotColor: 'text-fg-warning-primary' },
|
break: { label: 'Break', color: 'text-warning-primary', dotColor: 'text-fg-warning-primary' },
|
||||||
training: { label: 'Training', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
|
training: { label: 'Training', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
|
||||||
|
calling: { label: 'Calling', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
|
||||||
|
'in-call': { label: 'In Call', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
|
||||||
|
acw: { label: 'Wrapping up', color: 'text-warning-primary', dotColor: 'text-fg-warning-primary' },
|
||||||
offline: { label: 'Offline', color: 'text-tertiary', dotColor: 'text-fg-quaternary' },
|
offline: { label: 'Offline', color: 'text-tertiary', dotColor: 'text-fg-quaternary' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleOptions: Array<{ key: ToggleableStatus; label: string; color: string; dotColor: string }> = [
|
||||||
|
{ key: 'ready', label: 'Ready', color: 'text-success-primary', dotColor: 'text-fg-success-primary' },
|
||||||
|
{ key: 'break', label: 'Break', color: 'text-warning-primary', dotColor: 'text-fg-warning-primary' },
|
||||||
|
{ key: 'training', label: 'Training', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
|
||||||
|
];
|
||||||
|
|
||||||
type AgentStatusToggleProps = {
|
type AgentStatusToggleProps = {
|
||||||
isRegistered: boolean;
|
isRegistered: boolean;
|
||||||
connectionStatus: string;
|
connectionStatus: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatusToggleProps) => {
|
export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatusToggleProps) => {
|
||||||
const [status, setStatus] = useState<AgentStatus>(isRegistered ? 'ready' : 'offline');
|
const agentConfig = localStorage.getItem('helix_agent_config');
|
||||||
|
const agentId = agentConfig ? JSON.parse(agentConfig).ozonetelAgentId : null;
|
||||||
|
const ozonetelState = useAgentState(agentId);
|
||||||
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [changing, setChanging] = useState(false);
|
const [changing, setChanging] = useState(false);
|
||||||
|
|
||||||
const handleChange = async (newStatus: AgentStatus) => {
|
const handleChange = async (newStatus: ToggleableStatus) => {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
if (newStatus === status) return;
|
if (newStatus === ozonetelState) return;
|
||||||
setChanging(true);
|
setChanging(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (newStatus === 'ready') {
|
if (newStatus === 'ready') {
|
||||||
await apiClient.post('/api/ozonetel/agent-state', { state: 'Ready' });
|
console.log('[AGENT-STATE] Changing to Ready');
|
||||||
} else if (newStatus === 'offline') {
|
const res = await apiClient.post('/api/ozonetel/agent-state', { state: 'Ready' });
|
||||||
await apiClient.post('/api/ozonetel/agent-logout', {
|
console.log('[AGENT-STATE] Ready response:', JSON.stringify(res));
|
||||||
agentId: 'global',
|
|
||||||
password: 'Test123$',
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
const pauseReason = newStatus === 'break' ? 'Break' : 'Training';
|
const pauseReason = newStatus === 'break' ? 'Break' : 'Training';
|
||||||
await apiClient.post('/api/ozonetel/agent-state', { state: 'Pause', pauseReason });
|
console.log(`[AGENT-STATE] Changing to Pause: ${pauseReason}`);
|
||||||
|
const res = await apiClient.post('/api/ozonetel/agent-state', { state: 'Pause', pauseReason });
|
||||||
|
console.log('[AGENT-STATE] Pause response:', JSON.stringify(res));
|
||||||
}
|
}
|
||||||
setStatus(newStatus);
|
// Don't setStatus — SSE will push the real state
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('[AGENT-STATE] Status change failed:', err);
|
||||||
notify.error('Status Change Failed', 'Could not update agent status');
|
notify.error('Status Change Failed', 'Could not update agent status');
|
||||||
} finally {
|
} finally {
|
||||||
setChanging(false);
|
setChanging(false);
|
||||||
@@ -59,39 +73,40 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = statusConfig[status];
|
const current = displayConfig[ozonetelState] ?? displayConfig.offline;
|
||||||
|
const canToggle = ozonetelState === 'ready' || ozonetelState === 'break' || ozonetelState === 'training';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
onClick={() => canToggle && setMenuOpen(!menuOpen)}
|
||||||
disabled={changing}
|
disabled={changing || !canToggle}
|
||||||
className={cx(
|
className={cx(
|
||||||
'flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1 transition duration-100 ease-linear',
|
'flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1 transition duration-100 ease-linear',
|
||||||
'hover:bg-secondary_hover cursor-pointer',
|
canToggle ? 'hover:bg-secondary_hover cursor-pointer' : 'cursor-default',
|
||||||
changing && 'opacity-50',
|
changing && 'opacity-50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} />
|
<FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} />
|
||||||
<span className={cx('text-xs font-medium', current.color)}>{current.label}</span>
|
<span className={cx('text-xs font-medium', current.color)}>{current.label}</span>
|
||||||
<FontAwesomeIcon icon={faChevronDown} className="size-2.5 text-fg-quaternary" />
|
{canToggle && <FontAwesomeIcon icon={faChevronDown} className="size-2.5 text-fg-quaternary" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
||||||
<div className="absolute right-0 top-full z-50 mt-1 w-36 rounded-lg bg-primary shadow-lg ring-1 ring-secondary py-1">
|
<div className="absolute right-0 top-full z-50 mt-1 w-36 rounded-lg bg-primary shadow-lg ring-1 ring-secondary py-1">
|
||||||
{(Object.entries(statusConfig) as [AgentStatus, typeof current][]).map(([key, cfg]) => (
|
{toggleOptions.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={opt.key}
|
||||||
onClick={() => handleChange(key)}
|
onClick={() => handleChange(opt.key)}
|
||||||
className={cx(
|
className={cx(
|
||||||
'flex w-full items-center gap-2 px-3 py-2 text-xs font-medium transition duration-100 ease-linear',
|
'flex w-full items-center gap-2 px-3 py-2 text-xs font-medium transition duration-100 ease-linear',
|
||||||
key === status ? 'bg-active' : 'hover:bg-primary_hover',
|
opt.key === ozonetelState ? 'bg-active' : 'hover:bg-primary_hover',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faCircle} className={cx('size-2', cfg.dotColor)} />
|
<FontAwesomeIcon icon={faCircle} className={cx('size-2', opt.dotColor)} />
|
||||||
<span className={cfg.color}>{cfg.label}</span>
|
<span className={opt.color}>{opt.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const CalendarPlus02 = faIcon(faCalendarPlus);
|
|||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { TextArea } from '@/components/base/textarea/textarea';
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
import { AppointmentForm } from '@/components/call-desk/appointment-form';
|
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 { useSip } from '@/providers/sip-provider';
|
||||||
import { useAuth } from '@/providers/auth-provider';
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
@@ -41,20 +43,6 @@ const formatDuration = (seconds: number): string => {
|
|||||||
return `${m}:${s}`;
|
return `${m}:${s}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusDotColor: Record<string, string> = {
|
|
||||||
registered: 'bg-success-500',
|
|
||||||
connecting: 'bg-warning-500',
|
|
||||||
disconnected: 'bg-quaternary',
|
|
||||||
error: 'bg-error-500',
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusLabel: Record<string, string> = {
|
|
||||||
registered: 'Ready',
|
|
||||||
connecting: 'Connecting...',
|
|
||||||
disconnected: 'Offline',
|
|
||||||
error: 'Error',
|
|
||||||
};
|
|
||||||
|
|
||||||
const dispositionOptions: Array<{
|
const dispositionOptions: Array<{
|
||||||
value: CallDisposition;
|
value: CallDisposition;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -101,7 +89,6 @@ const dispositionOptions: Array<{
|
|||||||
|
|
||||||
export const CallWidget = () => {
|
export const CallWidget = () => {
|
||||||
const {
|
const {
|
||||||
connectionStatus,
|
|
||||||
callState,
|
callState,
|
||||||
callerNumber,
|
callerNumber,
|
||||||
isMuted,
|
isMuted,
|
||||||
@@ -114,6 +101,7 @@ export const CallWidget = () => {
|
|||||||
toggleHold,
|
toggleHold,
|
||||||
} = useSip();
|
} = useSip();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const setCallState = useSetAtom(sipCallStateAtom);
|
||||||
|
|
||||||
const [disposition, setDisposition] = useState<CallDisposition | null>(null);
|
const [disposition, setDisposition] = useState<CallDisposition | null>(null);
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
@@ -182,8 +170,20 @@ export const CallWidget = () => {
|
|||||||
}
|
}
|
||||||
}, [callState]);
|
}, [callState]);
|
||||||
|
|
||||||
|
// Auto-dismiss ended/failed state after 3 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (callState === 'ended' || callState === 'failed') {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
console.log('[CALL-WIDGET] Auto-dismissing ended/failed state');
|
||||||
|
setCallState('idle');
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [callState, setCallState]);
|
||||||
|
|
||||||
const handleSaveAndClose = async () => {
|
const handleSaveAndClose = async () => {
|
||||||
if (!disposition) return;
|
if (!disposition) return;
|
||||||
|
console.log(`[CALL-WIDGET] Save & Close: disposition=${disposition} lead=${matchedLead?.id ?? 'none'}`);
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -264,24 +264,16 @@ export const CallWidget = () => {
|
|||||||
setNotes('');
|
setNotes('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const dotColor = statusDotColor[connectionStatus] ?? 'bg-quaternary';
|
// Log state changes for observability
|
||||||
const label = statusLabel[connectionStatus] ?? connectionStatus;
|
useEffect(() => {
|
||||||
|
if (callState !== 'idle') {
|
||||||
|
console.log(`[CALL-WIDGET] State: ${callState} | caller=${callerNumber ?? 'none'}`);
|
||||||
|
}
|
||||||
|
}, [callState, callerNumber]);
|
||||||
|
|
||||||
// Idle: collapsed pill
|
// Idle: nothing to show — call desk has its own status toggle
|
||||||
if (callState === 'idle') {
|
if (callState === 'idle') {
|
||||||
return (
|
return null;
|
||||||
<div
|
|
||||||
className={cx(
|
|
||||||
'fixed bottom-6 right-6 z-50',
|
|
||||||
'inline-flex items-center gap-2 rounded-full border border-secondary bg-primary px-4 py-2.5 shadow-lg',
|
|
||||||
'transition-all duration-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cx('size-2.5 shrink-0 rounded-full', dotColor)} />
|
|
||||||
<span className="text-sm font-semibold text-secondary">{label}</span>
|
|
||||||
<span className="text-sm text-tertiary">Helix Phone</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ringing inbound
|
// Ringing inbound
|
||||||
@@ -444,6 +436,7 @@ export const CallWidget = () => {
|
|||||||
callerNumber={callerNumber}
|
callerNumber={callerNumber}
|
||||||
leadName={matchedLead ? `${matchedLead.contactName?.firstName ?? ''} ${matchedLead.contactName?.lastName ?? ''}`.trim() : null}
|
leadName={matchedLead ? `${matchedLead.contactName?.firstName ?? ''} ${matchedLead.contactName?.lastName ?? ''}`.trim() : null}
|
||||||
leadId={matchedLead?.id}
|
leadId={matchedLead?.id}
|
||||||
|
patientId={matchedLead?.patientId}
|
||||||
onSaved={() => {
|
onSaved={() => {
|
||||||
setIsAppointmentOpen(false);
|
setIsAppointmentOpen(false);
|
||||||
setDisposition('APPOINTMENT_BOOKED');
|
setDisposition('APPOINTMENT_BOOKED');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faSparkles, faMicrophone } from '@fortawesome/pro-duotone-svg-icons';
|
import { faSparkles, faMicrophone } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { formatTimeFull } from '@/lib/format';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
type TranscriptLine = {
|
type TranscriptLine = {
|
||||||
@@ -78,7 +79,7 @@ export const LiveTranscript = ({ transcript, suggestions, connected }: LiveTrans
|
|||||||
item.isFinal ? "text-primary" : "text-tertiary italic",
|
item.isFinal ? "text-primary" : "text-tertiary italic",
|
||||||
)}>
|
)}>
|
||||||
<span className="text-xs text-quaternary mr-2">
|
<span className="text-xs text-quaternary mr-2">
|
||||||
{item.timestamp.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
{formatTimeFull(item.timestamp.toISOString())}
|
||||||
</span>
|
</span>
|
||||||
{item.text}
|
{item.text}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Badge } from '@/components/base/badges/badges';
|
|||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||||
import { PhoneActionCell } from './phone-action-cell';
|
import { PhoneActionCell } from './phone-action-cell';
|
||||||
import { formatPhone } from '@/lib/format';
|
import { formatPhone, formatTimeOnly, formatShortDate } from '@/lib/format';
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
|||||||
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
|
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
|
||||||
typeLabel: 'Missed Call',
|
typeLabel: 'Missed Call',
|
||||||
reason: call.startedAt
|
reason: call.startedAt
|
||||||
? `Missed at ${new Date(call.startedAt).toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true })}${sourceSuffix}`
|
? `Missed at ${formatTimeOnly(call.startedAt)}${sourceSuffix}`
|
||||||
: 'Missed call',
|
: 'Missed call',
|
||||||
createdAt: call.createdAt,
|
createdAt: call.createdAt,
|
||||||
taskState: call.callbackstatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
|
taskState: call.callbackstatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
|
||||||
@@ -180,7 +180,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
|||||||
direction: null,
|
direction: null,
|
||||||
typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up',
|
typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up',
|
||||||
reason: fu.scheduledAt
|
reason: fu.scheduledAt
|
||||||
? `Scheduled ${new Date(fu.scheduledAt).toLocaleString('en-IN', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true })}`
|
? `Scheduled ${formatShortDate(fu.scheduledAt)}`
|
||||||
: '',
|
: '',
|
||||||
createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(),
|
createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(),
|
||||||
taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'),
|
taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const LinkExternal01: FC<{ className?: string }> = ({ className }) => <FontAweso
|
|||||||
|
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { CampaignStatusBadge } from '@/components/shared/status-badge';
|
import { CampaignStatusBadge } from '@/components/shared/status-badge';
|
||||||
|
import { formatDateOnly } from '@/lib/format';
|
||||||
import type { Campaign } from '@/types/entities';
|
import type { Campaign } from '@/types/entities';
|
||||||
|
|
||||||
interface CampaignHeroProps {
|
interface CampaignHeroProps {
|
||||||
@@ -15,12 +16,9 @@ interface CampaignHeroProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatDateRange = (startDate: string | null, endDate: string | null): string => {
|
const formatDateRange = (startDate: string | null, endDate: string | null): string => {
|
||||||
const fmt = (d: string) =>
|
|
||||||
new Intl.DateTimeFormat('en-IN', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date(d));
|
|
||||||
|
|
||||||
if (!startDate) return '--';
|
if (!startDate) return '--';
|
||||||
if (!endDate) return `${fmt(startDate)} \u2014 Ongoing`;
|
if (!endDate) return `${formatDateOnly(startDate)} \u2014 Ongoing`;
|
||||||
return `${fmt(startDate)} \u2014 ${fmt(endDate)}`;
|
return `${formatDateOnly(startDate)} \u2014 ${formatDateOnly(endDate)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDuration = (startDate: string | null, endDate: string | null): string => {
|
const formatDuration = (startDate: string | null, endDate: string | null): string => {
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { useLocation } from 'react-router';
|
|||||||
import { Sidebar } from './sidebar';
|
import { Sidebar } from './sidebar';
|
||||||
import { SipProvider } from '@/providers/sip-provider';
|
import { SipProvider } from '@/providers/sip-provider';
|
||||||
import { CallWidget } from '@/components/call-desk/call-widget';
|
import { CallWidget } from '@/components/call-desk/call-widget';
|
||||||
|
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
||||||
import { useAuth } from '@/providers/auth-provider';
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
|
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -12,6 +14,7 @@ interface AppShellProps {
|
|||||||
export const AppShell = ({ children }: AppShellProps) => {
|
export const AppShell = ({ children }: AppShellProps) => {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { isCCAgent } = useAuth();
|
const { isCCAgent } = useAuth();
|
||||||
|
const { isOpen, activeAction, close } = useMaintShortcuts();
|
||||||
|
|
||||||
// Heartbeat: keep agent session alive in Redis (CC agents only)
|
// Heartbeat: keep agent session alive in Redis (CC agents only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,6 +42,7 @@ export const AppShell = ({ children }: AppShellProps) => {
|
|||||||
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
|
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
|
||||||
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
|
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
|
||||||
</div>
|
</div>
|
||||||
|
<MaintOtpModal isOpen={isOpen} onOpenChange={(open) => !open && close()} action={activeAction} />
|
||||||
</SipProvider>
|
</SipProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
137
src/components/modals/maint-otp-modal.tsx
Normal file
137
src/components/modals/maint-otp-modal.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { REGEXP_ONLY_DIGITS } from 'input-otp';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
|
||||||
|
import { PinInput } from '@/components/base/pin-input/pin-input';
|
||||||
|
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faShieldKeyhole } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
|
|
||||||
|
const ShieldIcon: FC<{ className?: string }> = ({ className }) => (
|
||||||
|
<FontAwesomeIcon icon={faShieldKeyhole} className={className} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||||
|
|
||||||
|
type MaintAction = {
|
||||||
|
endpoint: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MaintOtpModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
action: MaintAction | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MaintOtpModal = ({ isOpen, onOpenChange, action }: MaintOtpModalProps) => {
|
||||||
|
const [otp, setOtp] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!action || otp.length < 6) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/maint/${action.endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-maint-otp': otp },
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
console.log(`[MAINT] ${action.label}:`, data);
|
||||||
|
notify.success(action.label, data.message ?? 'Completed successfully');
|
||||||
|
onOpenChange(false);
|
||||||
|
setOtp('');
|
||||||
|
} else {
|
||||||
|
setError(data.message ?? 'Failed');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Request failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOtpChange = (value: string) => {
|
||||||
|
setOtp(value);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
setOtp('');
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!action) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalOverlay isOpen={isOpen} onOpenChange={handleClose} isDismissable>
|
||||||
|
<Modal className="sm:max-w-[400px]">
|
||||||
|
<Dialog>
|
||||||
|
{() => (
|
||||||
|
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col items-center gap-4 px-6 pt-6 pb-5">
|
||||||
|
<FeaturedIcon icon={ShieldIcon} color="brand" theme="light" size="md" />
|
||||||
|
<div className="flex flex-col items-center gap-1 text-center">
|
||||||
|
<h2 className="text-lg font-semibold text-primary">{action.label}</h2>
|
||||||
|
<p className="text-sm text-tertiary">{action.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pin Input */}
|
||||||
|
<div className="flex flex-col items-center gap-2 px-6 pb-5">
|
||||||
|
<PinInput size="sm">
|
||||||
|
<PinInput.Label>Enter maintenance code</PinInput.Label>
|
||||||
|
<PinInput.Group
|
||||||
|
maxLength={6}
|
||||||
|
pattern={REGEXP_ONLY_DIGITS}
|
||||||
|
value={otp}
|
||||||
|
onChange={handleOtpChange}
|
||||||
|
onComplete={handleSubmit}
|
||||||
|
containerClassName="flex flex-row gap-2 h-14"
|
||||||
|
>
|
||||||
|
<PinInput.Slot index={0} className="!size-12 !text-display-sm" />
|
||||||
|
<PinInput.Slot index={1} className="!size-12 !text-display-sm" />
|
||||||
|
<PinInput.Slot index={2} className="!size-12 !text-display-sm" />
|
||||||
|
<PinInput.Separator />
|
||||||
|
<PinInput.Slot index={3} className="!size-12 !text-display-sm" />
|
||||||
|
<PinInput.Slot index={4} className="!size-12 !text-display-sm" />
|
||||||
|
<PinInput.Slot index={5} className="!size-12 !text-display-sm" />
|
||||||
|
</PinInput.Group>
|
||||||
|
</PinInput>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-error-primary mt-1">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center gap-3 border-t border-secondary px-6 py-4">
|
||||||
|
<Button size="md" color="secondary" onClick={handleClose} className="flex-1">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
color="primary"
|
||||||
|
isDisabled={otp.length < 6 || loading}
|
||||||
|
isLoading={loading}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import { faIcon } from '@/lib/icon-wrapper';
|
|||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { formatShortDate } from '@/lib/format';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
const SearchIcon = faIcon(faMagnifyingGlass);
|
const SearchIcon = faIcon(faMagnifyingGlass);
|
||||||
@@ -97,7 +98,7 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const a of data.appointments ?? []) {
|
for (const a of data.appointments ?? []) {
|
||||||
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '';
|
const date = a.scheduledAt ? formatShortDate(a.scheduledAt) : '';
|
||||||
searchResults.push({
|
searchResults.push({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
type: 'appointment',
|
type: 'appointment',
|
||||||
|
|||||||
78
src/hooks/use-agent-state.ts
Normal file
78
src/hooks/use-agent-state.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
|
|
||||||
|
export type OzonetelState = 'ready' | 'break' | 'training' | 'calling' | 'in-call' | 'acw' | 'offline';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||||
|
|
||||||
|
export const useAgentState = (agentId: string | null): OzonetelState => {
|
||||||
|
const [state, setState] = useState<OzonetelState>('offline');
|
||||||
|
const prevStateRef = useRef<OzonetelState>('offline');
|
||||||
|
const esRef = useRef<EventSource | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!agentId) {
|
||||||
|
setState('offline');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch current state on connect
|
||||||
|
fetch(`${API_URL}/api/supervisor/agent-state?agentId=${agentId}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.state) {
|
||||||
|
console.log(`[SSE] Initial state for ${agentId}: ${data.state}`);
|
||||||
|
prevStateRef.current = data.state;
|
||||||
|
setState(data.state);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Open SSE stream
|
||||||
|
const url = `${API_URL}/api/supervisor/agent-state/stream?agentId=${agentId}`;
|
||||||
|
console.log(`[SSE] Connecting: ${url}`);
|
||||||
|
const es = new EventSource(url);
|
||||||
|
esRef.current = es;
|
||||||
|
|
||||||
|
es.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
console.log(`[SSE] State update: ${agentId} → ${data.state}`);
|
||||||
|
|
||||||
|
// Force-logout: only triggered by explicit admin action, not normal Ozonetel logout
|
||||||
|
if (data.state === 'force-logout') {
|
||||||
|
console.log('[SSE] Force-logout received — clearing session');
|
||||||
|
notify.info('Session Ended', 'Your session was ended by an administrator.');
|
||||||
|
es.close();
|
||||||
|
|
||||||
|
localStorage.removeItem('helix_access_token');
|
||||||
|
localStorage.removeItem('helix_refresh_token');
|
||||||
|
localStorage.removeItem('helix_agent_config');
|
||||||
|
localStorage.removeItem('helix_user');
|
||||||
|
|
||||||
|
import('@/state/sip-manager').then(({ disconnectSip }) => disconnectSip()).catch(() => {});
|
||||||
|
|
||||||
|
setTimeout(() => { window.location.href = '/login'; }, 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
prevStateRef.current = data.state;
|
||||||
|
setState(data.state);
|
||||||
|
} catch {
|
||||||
|
console.warn('[SSE] Failed to parse event:', event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
console.warn('[SSE] Connection error — will auto-reconnect');
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log('[SSE] Closing connection');
|
||||||
|
es.close();
|
||||||
|
esRef.current = null;
|
||||||
|
};
|
||||||
|
}, [agentId]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
71
src/hooks/use-maint-shortcuts.ts
Normal file
71
src/hooks/use-maint-shortcuts.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
export type MaintAction = {
|
||||||
|
endpoint: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAINT_ACTIONS: Record<string, MaintAction> = {
|
||||||
|
forceReady: {
|
||||||
|
endpoint: 'force-ready',
|
||||||
|
label: 'Force Ready',
|
||||||
|
description: 'Logout and re-login the agent to force Ready state on Ozonetel.',
|
||||||
|
},
|
||||||
|
unlockAgent: {
|
||||||
|
endpoint: 'unlock-agent',
|
||||||
|
label: 'Unlock Agent',
|
||||||
|
description: 'Release the Redis session lock so the agent can log in again.',
|
||||||
|
},
|
||||||
|
backfill: {
|
||||||
|
endpoint: 'backfill-missed-calls',
|
||||||
|
label: 'Backfill Missed Calls',
|
||||||
|
description: 'Match existing missed calls with lead records by phone number.',
|
||||||
|
},
|
||||||
|
fixTimestamps: {
|
||||||
|
endpoint: 'fix-timestamps',
|
||||||
|
label: 'Fix Timestamps',
|
||||||
|
description: 'Correct call timestamps that were stored with IST double-offset.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMaintShortcuts = () => {
|
||||||
|
const [activeAction, setActiveAction] = useState<MaintAction | null>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const openAction = useCallback((action: MaintAction) => {
|
||||||
|
setActiveAction(action);
|
||||||
|
setIsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setActiveAction(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.key === 'R') {
|
||||||
|
e.preventDefault();
|
||||||
|
openAction(MAINT_ACTIONS.forceReady);
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.key === 'U') {
|
||||||
|
e.preventDefault();
|
||||||
|
openAction(MAINT_ACTIONS.unlockAgent);
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.key === 'B') {
|
||||||
|
e.preventDefault();
|
||||||
|
openAction(MAINT_ACTIONS.backfill);
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
|
||||||
|
e.preventDefault();
|
||||||
|
openAction(MAINT_ACTIONS.fixTimestamps);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [openAction]);
|
||||||
|
|
||||||
|
return { isOpen, activeAction, close };
|
||||||
|
};
|
||||||
@@ -26,10 +26,33 @@ export const formatRelativeAge = (dateStr: string): string => {
|
|||||||
return `${days} days ago`;
|
return `${days} days ago`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format short date (Mar 15, 2:30 PM)
|
// All date formatting uses browser's local timezone (no hardcoded TZ)
|
||||||
|
// Timestamps from the API are UTC — Intl.DateTimeFormat converts to local automatically
|
||||||
|
|
||||||
|
// Mar 15, 2:30 PM
|
||||||
export const formatShortDate = (dateStr: string): string =>
|
export const formatShortDate = (dateStr: string): string =>
|
||||||
new Intl.DateTimeFormat('en-IN', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }).format(new Date(dateStr));
|
new Intl.DateTimeFormat('en-IN', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }).format(new Date(dateStr));
|
||||||
|
|
||||||
|
// 15 Mar 2026
|
||||||
|
export const formatDateOnly = (dateStr: string): string =>
|
||||||
|
new Intl.DateTimeFormat('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }).format(new Date(dateStr));
|
||||||
|
|
||||||
|
// 2:30 PM
|
||||||
|
export const formatTimeOnly = (dateStr: string): string =>
|
||||||
|
new Intl.DateTimeFormat('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true }).format(new Date(dateStr));
|
||||||
|
|
||||||
|
// Mar 15, 2:30 PM (with day + month + time, no year)
|
||||||
|
export const formatDateTimeShort = (dateStr: string): string =>
|
||||||
|
new Intl.DateTimeFormat('en-IN', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit', hour12: true }).format(new Date(dateStr));
|
||||||
|
|
||||||
|
// Mon, 15
|
||||||
|
export const formatWeekdayShort = (dateStr: string): string =>
|
||||||
|
new Intl.DateTimeFormat('en-IN', { weekday: 'short', day: 'numeric' }).format(new Date(dateStr));
|
||||||
|
|
||||||
|
// 02:30:45 PM
|
||||||
|
export const formatTimeFull = (dateStr: string): string =>
|
||||||
|
new Intl.DateTimeFormat('en-IN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true }).format(new Date(dateStr));
|
||||||
|
|
||||||
// Get initials from a name
|
// Get initials from a name
|
||||||
export const getInitials = (firstName: string, lastName: string): string =>
|
export const getInitials = (firstName: string, lastName: string): string =>
|
||||||
`${firstName[0] || ''}${lastName[0] || ''}`.toUpperCase();
|
`${firstName[0] || ''}${lastName[0] || ''}`.toUpperCase();
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ export class SIPClient {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
connect(): void {
|
connect(): void {
|
||||||
JsSIP.debug.enable('JsSIP:*');
|
// Disable verbose JsSIP protocol logging — we log lifecycle events ourselves
|
||||||
|
JsSIP.debug.disable('JsSIP:*');
|
||||||
|
|
||||||
const socket = new JsSIP.WebSocketInterface(this.config.wsServer);
|
const socket = new JsSIP.WebSocketInterface(this.config.wsServer);
|
||||||
const sipId = this.config.uri.replace('sip:', '').split('@')[0];
|
const sipId = this.config.uri.replace('sip:', '').split('@')[0];
|
||||||
@@ -36,22 +37,27 @@ export class SIPClient {
|
|||||||
this.ua = new JsSIP.UA(configuration);
|
this.ua = new JsSIP.UA(configuration);
|
||||||
|
|
||||||
this.ua.on('connected', () => {
|
this.ua.on('connected', () => {
|
||||||
|
console.log('[SIP] WebSocket connected');
|
||||||
this.onConnectionChange('connected');
|
this.onConnectionChange('connected');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ua.on('disconnected', () => {
|
this.ua.on('disconnected', () => {
|
||||||
|
console.log('[SIP] WebSocket disconnected');
|
||||||
this.onConnectionChange('disconnected');
|
this.onConnectionChange('disconnected');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ua.on('registered', () => {
|
this.ua.on('registered', () => {
|
||||||
|
console.log('[SIP] Registered successfully');
|
||||||
this.onConnectionChange('registered');
|
this.onConnectionChange('registered');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ua.on('unregistered', () => {
|
this.ua.on('unregistered', () => {
|
||||||
|
console.log('[SIP] Unregistered');
|
||||||
this.onConnectionChange('disconnected');
|
this.onConnectionChange('disconnected');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ua.on('registrationFailed', () => {
|
this.ua.on('registrationFailed', () => {
|
||||||
|
console.error('[SIP] Registration failed');
|
||||||
this.onConnectionChange('error');
|
this.onConnectionChange('error');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,6 +77,8 @@ export class SIPClient {
|
|||||||
const callerNumber = this.extractCallerNumber(session, sipRequest);
|
const callerNumber = this.extractCallerNumber(session, sipRequest);
|
||||||
const ucid = sipRequest?.getHeader ? sipRequest.getHeader('X-UCID') ?? null : null;
|
const ucid = sipRequest?.getHeader ? sipRequest.getHeader('X-UCID') ?? null : null;
|
||||||
|
|
||||||
|
console.log(`[SIP] New session: direction=${session.direction} caller=${callerNumber} ucid=${ucid ?? 'none'}`);
|
||||||
|
|
||||||
// Setup audio for this session
|
// Setup audio for this session
|
||||||
session.on('peerconnection', (e: PeerConnectionEvent) => {
|
session.on('peerconnection', (e: PeerConnectionEvent) => {
|
||||||
const pc = e.peerconnection;
|
const pc = e.peerconnection;
|
||||||
@@ -85,6 +93,7 @@ export class SIPClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
session.on('accepted', (() => {
|
session.on('accepted', (() => {
|
||||||
|
console.log(`[SIP] Call accepted — ucid=${ucid ?? 'none'}`);
|
||||||
this.onCallStateChange('active', callerNumber, ucid ?? undefined);
|
this.onCallStateChange('active', callerNumber, ucid ?? undefined);
|
||||||
}) as CallListener);
|
}) as CallListener);
|
||||||
|
|
||||||
@@ -98,12 +107,14 @@ export class SIPClient {
|
|||||||
}
|
}
|
||||||
}) as CallListener);
|
}) as CallListener);
|
||||||
|
|
||||||
session.on('failed', (_e: EndEvent) => {
|
session.on('failed', (e: EndEvent) => {
|
||||||
|
console.log(`[SIP] Call failed — cause=${(e as any).cause ?? 'unknown'} ucid=${ucid ?? 'none'}`);
|
||||||
this.resetSession();
|
this.resetSession();
|
||||||
this.onCallStateChange('failed');
|
this.onCallStateChange('failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
session.on('ended', (_e: EndEvent) => {
|
session.on('ended', (e: EndEvent) => {
|
||||||
|
console.log(`[SIP] Call ended — cause=${(e as any).cause ?? 'normal'} ucid=${ucid ?? 'none'}`);
|
||||||
this.resetSession();
|
this.resetSession();
|
||||||
this.onCallStateChange('ended');
|
this.onCallStateChange('ended');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Table } from '@/components/application/table/table';
|
|||||||
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||||
import { formatPhone } from '@/lib/format';
|
import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
|
||||||
type AppointmentRecord = {
|
type AppointmentRecord = {
|
||||||
@@ -60,15 +60,9 @@ const QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast
|
|||||||
doctor { clinic { clinicName } }
|
doctor { clinic { clinicName } }
|
||||||
} } } }`;
|
} } } }`;
|
||||||
|
|
||||||
const formatDate = (iso: string): string => {
|
const formatDate = (iso: string): string => formatDateOnly(iso);
|
||||||
const d = new Date(iso);
|
|
||||||
return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (iso: string): string => {
|
const formatTime = (iso: string): string => formatTimeOnly(iso);
|
||||||
const d = new Date(iso);
|
|
||||||
return d.toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AppointmentsPage = () => {
|
export const AppointmentsPage = () => {
|
||||||
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
|
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
|
||||||
|
|||||||
@@ -108,6 +108,13 @@ export const CallHistoryPage = () => {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filter, setFilter] = useState<FilterKey>('all');
|
const [filter, setFilter] = useState<FilterKey>('all');
|
||||||
|
|
||||||
|
// Debug: log first call's raw timestamp to diagnose timezone issue
|
||||||
|
if (calls.length > 0 && !(window as any).__callTimestampLogged) {
|
||||||
|
const c = calls[0];
|
||||||
|
console.log(`[DEBUG-TIME] Raw startedAt="${c.startedAt}" → parsed=${new Date(c.startedAt!)} → formatted="${c.startedAt ? new Intl.DateTimeFormat('en-IN', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }).format(new Date(c.startedAt)) : 'n/a'}" | direction=${c.callDirection} status=${c.callStatus}`);
|
||||||
|
(window as any).__callTimestampLogged = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Build a map of lead names by ID for enrichment
|
// Build a map of lead names by ID for enrichment
|
||||||
const leadNameMap = useMemo(() => {
|
const leadNameMap = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
@@ -151,20 +158,19 @@ export const CallHistoryPage = () => {
|
|||||||
return result;
|
return result;
|
||||||
}, [calls, filter, search, leadNameMap]);
|
}, [calls, filter, search, leadNameMap]);
|
||||||
|
|
||||||
const inboundCount = calls.filter((c) => c.callDirection === 'INBOUND').length;
|
const completedCount = filteredCalls.filter((c) => c.callStatus !== 'MISSED').length;
|
||||||
const outboundCount = calls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
const missedCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length;
|
||||||
const missedCount = calls.filter((c) => c.callStatus === 'MISSED').length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<TopBar title="Call History" subtitle={`${calls.length} total calls`} />
|
<TopBar title="Call History" subtitle={`${filteredCalls.length} total calls`} />
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-7">
|
<div className="flex-1 overflow-y-auto p-7">
|
||||||
<TableCard.Root size="md">
|
<TableCard.Root size="md">
|
||||||
<TableCard.Header
|
<TableCard.Header
|
||||||
title="Call History"
|
title="Call History"
|
||||||
badge={String(filteredCalls.length)}
|
badge={String(filteredCalls.length)}
|
||||||
description={`${inboundCount} inbound \u00B7 ${outboundCount} outbound \u00B7 ${missedCount} missed`}
|
description={`${completedCount} completed \u00B7 ${missedCount} missed`}
|
||||||
contentTrailing={
|
contentTrailing={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-44">
|
<div className="w-44">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Table } from '@/components/application/table/table';
|
|||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { formatPhone } from '@/lib/format';
|
import { formatPhone, formatDateOnly } from '@/lib/format';
|
||||||
|
|
||||||
type RecordingRecord = {
|
type RecordingRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,8 +30,7 @@ const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { ed
|
|||||||
recording { primaryLinkUrl primaryLinkLabel }
|
recording { primaryLinkUrl primaryLinkLabel }
|
||||||
} } } }`;
|
} } } }`;
|
||||||
|
|
||||||
const formatDate = (iso: string): string =>
|
const formatDate = (iso: string): string => formatDateOnly(iso);
|
||||||
new Date(iso).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
||||||
|
|
||||||
const formatDuration = (sec: number | null): string => {
|
const formatDuration = (sec: number | null): string => {
|
||||||
if (!sec) return '—';
|
if (!sec) return '—';
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { HealthIndicator } from '@/components/campaigns/health-indicator';
|
|||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { useCampaigns } from '@/hooks/use-campaigns';
|
import { useCampaigns } from '@/hooks/use-campaigns';
|
||||||
import { useLeads } from '@/hooks/use-leads';
|
import { useLeads } from '@/hooks/use-leads';
|
||||||
import { formatCurrency } from '@/lib/format';
|
import { formatCurrency, formatDateOnly } from '@/lib/format';
|
||||||
|
|
||||||
const detailTabs = [
|
const detailTabs = [
|
||||||
{ id: 'overview', label: 'Overview' },
|
{ id: 'overview', label: 'Overview' },
|
||||||
@@ -41,9 +41,7 @@ export const CampaignDetailPage = () => {
|
|||||||
|
|
||||||
const formatDateShort = (dateStr: string | null) => {
|
const formatDateShort = (dateStr: string | null) => {
|
||||||
if (!dateStr) return '--';
|
if (!dateStr) return '--';
|
||||||
return new Intl.DateTimeFormat('en-IN', { month: 'short', day: 'numeric', year: 'numeric' }).format(
|
return formatDateOnly(dateStr);
|
||||||
new Date(dateStr),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ import { Button } from '@/components/base/buttons/button';
|
|||||||
import { SocialButton } from '@/components/base/buttons/social-button';
|
import { SocialButton } from '@/components/base/buttons/social-button';
|
||||||
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
||||||
|
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
||||||
|
|
||||||
export const LoginPage = () => {
|
export const LoginPage = () => {
|
||||||
const { loginWithUser } = useAuth();
|
const { loginWithUser } = useAuth();
|
||||||
const { refresh } = useData();
|
const { refresh } = useData();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { isOpen, activeAction, close } = useMaintShortcuts();
|
||||||
|
|
||||||
const saved = localStorage.getItem('helix_remember');
|
const saved = localStorage.getItem('helix_remember');
|
||||||
const savedCreds = saved ? JSON.parse(saved) : null;
|
const savedCreds = saved ? JSON.parse(saved) : null;
|
||||||
@@ -176,6 +179,8 @@ export const LoginPage = () => {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<a href="https://f0rty2.ai" target="_blank" rel="noopener noreferrer" className="mt-6 text-xs text-primary_on-brand opacity-60 hover:opacity-90 transition duration-100 ease-linear">Powered by F0rty2.ai</a>
|
<a href="https://f0rty2.ai" target="_blank" rel="noopener noreferrer" className="mt-6 text-xs text-primary_on-brand opacity-60 hover:opacity-90 transition duration-100 ease-linear">Powered by F0rty2.ai</a>
|
||||||
|
|
||||||
|
<MaintOtpModal isOpen={isOpen} onOpenChange={(open) => !open && close()} action={activeAction} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
|||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { formatPhone } from '@/lib/format';
|
import { formatPhone, formatDateTimeShort } from '@/lib/format';
|
||||||
|
|
||||||
type MissedCallRecord = {
|
type MissedCallRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,8 +32,7 @@ const QUERY = `{ calls(first: 200, filter: {
|
|||||||
startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat
|
startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat
|
||||||
} } } }`;
|
} } } }`;
|
||||||
|
|
||||||
const formatDate = (iso: string): string =>
|
const formatDate = (iso: string): string => formatDateTimeShort(iso);
|
||||||
new Date(iso).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit', hour12: true });
|
|
||||||
|
|
||||||
const computeSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => {
|
const computeSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => {
|
||||||
const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
|
const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export const useSip = () => {
|
|||||||
const [connectionStatus] = useAtom(sipConnectionStatusAtom);
|
const [connectionStatus] = useAtom(sipConnectionStatusAtom);
|
||||||
const [callState, setCallState] = useAtom(sipCallStateAtom);
|
const [callState, setCallState] = useAtom(sipCallStateAtom);
|
||||||
const [callerNumber, setCallerNumber] = useAtom(sipCallerNumberAtom);
|
const [callerNumber, setCallerNumber] = useAtom(sipCallerNumberAtom);
|
||||||
const [callUcid] = useAtom(sipCallUcidAtom);
|
const [callUcid, setCallUcid] = useAtom(sipCallUcidAtom);
|
||||||
const [isMuted, setIsMuted] = useAtom(sipIsMutedAtom);
|
const [isMuted, setIsMuted] = useAtom(sipIsMutedAtom);
|
||||||
const [isOnHold, setIsOnHold] = useAtom(sipIsOnHoldAtom);
|
const [isOnHold, setIsOnHold] = useAtom(sipIsOnHoldAtom);
|
||||||
const [callDuration] = useAtom(sipCallDurationAtom);
|
const [callDuration] = useAtom(sipCallDurationAtom);
|
||||||
@@ -116,21 +116,33 @@ export const useSip = () => {
|
|||||||
|
|
||||||
// Ozonetel outbound dial — single path for all outbound calls
|
// Ozonetel outbound dial — single path for all outbound calls
|
||||||
const dialOutbound = useCallback(async (phoneNumber: string): Promise<void> => {
|
const dialOutbound = useCallback(async (phoneNumber: string): Promise<void> => {
|
||||||
|
console.log(`[DIAL] Outbound dial started: phone=${phoneNumber}`);
|
||||||
setCallState('ringing-out');
|
setCallState('ringing-out');
|
||||||
setCallerNumber(phoneNumber);
|
setCallerNumber(phoneNumber);
|
||||||
setOutboundPending(true);
|
setOutboundPending(true);
|
||||||
const safetyTimeout = setTimeout(() => setOutboundPending(false), 30000);
|
const safetyTimeout = setTimeout(() => {
|
||||||
|
console.warn('[DIAL] Safety timeout fired (30s) — clearing outboundPending');
|
||||||
|
setOutboundPending(false);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.post('/api/ozonetel/dial', { phoneNumber });
|
const result = await apiClient.post<{ status: string; ucid?: string }>('/api/ozonetel/dial', { phoneNumber });
|
||||||
} catch {
|
console.log('[DIAL] Dial API response:', result);
|
||||||
|
clearTimeout(safetyTimeout);
|
||||||
|
// Store UCID from dial response — SIP bridge doesn't carry X-UCID for outbound
|
||||||
|
if (result?.ucid) {
|
||||||
|
console.log(`[DIAL] Storing UCID from dial response: ${result.ucid}`);
|
||||||
|
setCallUcid(result.ucid);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DIAL] Dial API failed:', err);
|
||||||
clearTimeout(safetyTimeout);
|
clearTimeout(safetyTimeout);
|
||||||
setOutboundPending(false);
|
setOutboundPending(false);
|
||||||
setCallState('idle');
|
setCallState('idle');
|
||||||
setCallerNumber(null);
|
setCallerNumber(null);
|
||||||
throw new Error('Dial failed');
|
throw new Error('Dial failed');
|
||||||
}
|
}
|
||||||
}, [setCallState, setCallerNumber]);
|
}, [setCallState, setCallerNumber, setCallUcid]);
|
||||||
|
|
||||||
const answer = useCallback(() => getSipClient()?.answer(), []);
|
const answer = useCallback(() => getSipClient()?.answer(), []);
|
||||||
const reject = useCallback(() => getSipClient()?.reject(), []);
|
const reject = useCallback(() => getSipClient()?.reject(), []);
|
||||||
|
|||||||
@@ -53,26 +53,24 @@ export function connectSip(config: SIPConfig): void {
|
|||||||
if (state === 'ringing-in' && outboundPending) {
|
if (state === 'ringing-in' && outboundPending) {
|
||||||
outboundPending = false;
|
outboundPending = false;
|
||||||
outboundActive = true;
|
outboundActive = true;
|
||||||
console.log('[SIP] Outbound bridge detected — auto-answering');
|
console.log('[SIP-MGR] Outbound bridge detected — auto-answering');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
sipClient?.answer();
|
sipClient?.answer();
|
||||||
setTimeout(() => stateUpdater?.setCallState('active'), 300);
|
setTimeout(() => stateUpdater?.setCallState('active'), 300);
|
||||||
}, 500);
|
}, 500);
|
||||||
// Store UCID even for outbound bridge calls
|
|
||||||
if (ucid) stateUpdater?.setCallUcid(ucid);
|
if (ucid) stateUpdater?.setCallUcid(ucid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't overwrite caller number on outbound calls — it was set by click-to-call
|
console.log(`[SIP-MGR] State: ${state} | caller=${number ?? 'none'} | ucid=${ucid ?? 'none'} | outboundActive=${outboundActive}`);
|
||||||
|
|
||||||
stateUpdater?.setCallState(state);
|
stateUpdater?.setCallState(state);
|
||||||
if (!outboundActive && number !== undefined) {
|
if (!outboundActive && number !== undefined) {
|
||||||
stateUpdater?.setCallerNumber(number ?? null);
|
stateUpdater?.setCallerNumber(number ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store UCID if provided
|
|
||||||
if (ucid) stateUpdater?.setCallUcid(ucid);
|
if (ucid) stateUpdater?.setCallUcid(ucid);
|
||||||
|
|
||||||
// Reset outbound flag when call ends
|
|
||||||
if (state === 'ended' || state === 'failed') {
|
if (state === 'ended' || state === 'failed') {
|
||||||
outboundActive = false;
|
outboundActive = false;
|
||||||
outboundPending = false;
|
outboundPending = false;
|
||||||
@@ -84,6 +82,7 @@ export function connectSip(config: SIPConfig): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function disconnectSip(): void {
|
export function disconnectSip(): void {
|
||||||
|
console.log('[SIP-MGR] Disconnecting SIP');
|
||||||
sipClient?.disconnect();
|
sipClient?.disconnect();
|
||||||
sipClient = null;
|
sipClient = null;
|
||||||
connected = false;
|
connected = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user