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:
2026-03-24 22:03:48 +05:30
parent ae94a390df
commit 488f524f84
21 changed files with 462 additions and 107 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
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)
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 lastName = lead?.contactName?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim();
@@ -62,7 +67,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
// Submit disposition to sidecar — handles Ozonetel ACW release
if (callUcid) {
apiClient.post('/api/ozonetel/dispose', {
const disposePayload = {
ucid: callUcid,
disposition,
callerPhone,
@@ -71,7 +76,13 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
leadId: lead?.id ?? null,
notes,
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

View File

@@ -1,48 +1,62 @@
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
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 { notify } from '@/lib/toast';
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' },
break: { label: 'Break', color: 'text-warning-primary', dotColor: 'text-fg-warning-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' },
};
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 = {
isRegistered: boolean;
connectionStatus: string;
};
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 [changing, setChanging] = useState(false);
const handleChange = async (newStatus: AgentStatus) => {
const handleChange = async (newStatus: ToggleableStatus) => {
setMenuOpen(false);
if (newStatus === status) return;
if (newStatus === ozonetelState) return;
setChanging(true);
try {
if (newStatus === 'ready') {
await apiClient.post('/api/ozonetel/agent-state', { state: 'Ready' });
} else if (newStatus === 'offline') {
await apiClient.post('/api/ozonetel/agent-logout', {
agentId: 'global',
password: 'Test123$',
});
console.log('[AGENT-STATE] Changing to Ready');
const res = await apiClient.post('/api/ozonetel/agent-state', { state: 'Ready' });
console.log('[AGENT-STATE] Ready response:', JSON.stringify(res));
} else {
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);
} catch {
// Don't setStatus — SSE will push the real state
} catch (err) {
console.error('[AGENT-STATE] Status change failed:', err);
notify.error('Status Change Failed', 'Could not update agent status');
} finally {
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 (
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
disabled={changing}
onClick={() => canToggle && setMenuOpen(!menuOpen)}
disabled={changing || !canToggle}
className={cx(
'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',
)}
>
<FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} />
<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>
{menuOpen && (
<>
<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">
{(Object.entries(statusConfig) as [AgentStatus, typeof current][]).map(([key, cfg]) => (
{toggleOptions.map((opt) => (
<button
key={key}
onClick={() => handleChange(key)}
key={opt.key}
onClick={() => handleChange(opt.key)}
className={cx(
'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)} />
<span className={cfg.color}>{cfg.label}</span>
<FontAwesomeIcon icon={faCircle} className={cx('size-2', opt.dotColor)} />
<span className={opt.color}>{opt.label}</span>
</button>
))}
</div>

View File

@@ -28,6 +28,8 @@ 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';
@@ -41,20 +43,6 @@ const formatDuration = (seconds: number): string => {
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<{
value: CallDisposition;
label: string;
@@ -101,7 +89,6 @@ const dispositionOptions: Array<{
export const CallWidget = () => {
const {
connectionStatus,
callState,
callerNumber,
isMuted,
@@ -114,6 +101,7 @@ export const CallWidget = () => {
toggleHold,
} = useSip();
const { user } = useAuth();
const setCallState = useSetAtom(sipCallStateAtom);
const [disposition, setDisposition] = useState<CallDisposition | null>(null);
const [notes, setNotes] = useState('');
@@ -182,8 +170,20 @@ export const CallWidget = () => {
}
}, [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 () => {
if (!disposition) return;
console.log(`[CALL-WIDGET] Save & Close: disposition=${disposition} lead=${matchedLead?.id ?? 'none'}`);
setIsSaving(true);
try {
@@ -264,24 +264,16 @@ export const CallWidget = () => {
setNotes('');
};
const dotColor = statusDotColor[connectionStatus] ?? 'bg-quaternary';
const label = statusLabel[connectionStatus] ?? connectionStatus;
// Log state changes for observability
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') {
return (
<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>
);
return null;
}
// Ringing inbound
@@ -444,6 +436,7 @@ export const CallWidget = () => {
callerNumber={callerNumber}
leadName={matchedLead ? `${matchedLead.contactName?.firstName ?? ''} ${matchedLead.contactName?.lastName ?? ''}`.trim() : null}
leadId={matchedLead?.id}
patientId={matchedLead?.patientId}
onSaved={() => {
setIsAppointmentOpen(false);
setDisposition('APPOINTMENT_BOOKED');

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSparkles, faMicrophone } from '@fortawesome/pro-duotone-svg-icons';
import { formatTimeFull } from '@/lib/format';
import { cx } from '@/utils/cx';
type TranscriptLine = {
@@ -78,7 +79,7 @@ export const LiveTranscript = ({ transcript, suggestions, connected }: LiveTrans
item.isFinal ? "text-primary" : "text-tertiary italic",
)}>
<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>
{item.text}
</div>

View File

@@ -8,7 +8,7 @@ import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { PhoneActionCell } from './phone-action-cell';
import { formatPhone } from '@/lib/format';
import { formatPhone, formatTimeOnly, formatShortDate } from '@/lib/format';
import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
@@ -154,7 +154,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
typeLabel: 'Missed Call',
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',
createdAt: call.createdAt,
taskState: call.callbackstatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
@@ -180,7 +180,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
direction: null,
typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up',
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(),
taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'),

View File

@@ -8,6 +8,7 @@ const LinkExternal01: FC<{ className?: string }> = ({ className }) => <FontAweso
import { Button } from '@/components/base/buttons/button';
import { CampaignStatusBadge } from '@/components/shared/status-badge';
import { formatDateOnly } from '@/lib/format';
import type { Campaign } from '@/types/entities';
interface CampaignHeroProps {
@@ -15,12 +16,9 @@ interface CampaignHeroProps {
}
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 (!endDate) return `${fmt(startDate)} \u2014 Ongoing`;
return `${fmt(startDate)} \u2014 ${fmt(endDate)}`;
if (!endDate) return `${formatDateOnly(startDate)} \u2014 Ongoing`;
return `${formatDateOnly(startDate)} \u2014 ${formatDateOnly(endDate)}`;
};
const formatDuration = (startDate: string | null, endDate: string | null): string => {

View File

@@ -3,7 +3,9 @@ import { useLocation } from 'react-router';
import { Sidebar } from './sidebar';
import { SipProvider } from '@/providers/sip-provider';
import { CallWidget } from '@/components/call-desk/call-widget';
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
import { useAuth } from '@/providers/auth-provider';
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
interface AppShellProps {
children: ReactNode;
@@ -12,6 +14,7 @@ interface AppShellProps {
export const AppShell = ({ children }: AppShellProps) => {
const { pathname } = useLocation();
const { isCCAgent } = useAuth();
const { isOpen, activeAction, close } = useMaintShortcuts();
// Heartbeat: keep agent session alive in Redis (CC agents only)
useEffect(() => {
@@ -39,6 +42,7 @@ export const AppShell = ({ children }: AppShellProps) => {
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
</div>
<MaintOtpModal isOpen={isOpen} onOpenChange={(open) => !open && close()} action={activeAction} />
</SipProvider>
);
};

View 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>
);
};

View File

@@ -5,6 +5,7 @@ import { faIcon } from '@/lib/icon-wrapper';
import { Input } from '@/components/base/input/input';
import { Badge } from '@/components/base/badges/badges';
import { apiClient } from '@/lib/api-client';
import { formatShortDate } from '@/lib/format';
import { cx } from '@/utils/cx';
const SearchIcon = faIcon(faMagnifyingGlass);
@@ -97,7 +98,7 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
}
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({
id: a.id,
type: 'appointment',