From 488f524f84c85344a3804722226ad51486b07ffa Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Tue, 24 Mar 2026 22:03:48 +0530 Subject: [PATCH] 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) --- src/components/call-desk/active-call-card.tsx | 17 ++- .../call-desk/agent-status-toggle.tsx | 65 +++++---- src/components/call-desk/call-widget.tsx | 55 +++---- src/components/call-desk/live-transcript.tsx | 3 +- src/components/call-desk/worklist-panel.tsx | 6 +- src/components/campaigns/campaign-hero.tsx | 8 +- src/components/layout/app-shell.tsx | 4 + src/components/modals/maint-otp-modal.tsx | 137 ++++++++++++++++++ src/components/shared/global-search.tsx | 3 +- src/hooks/use-agent-state.ts | 78 ++++++++++ src/hooks/use-maint-shortcuts.ts | 71 +++++++++ src/lib/format.ts | 25 +++- src/lib/sip-client.ts | 17 ++- src/pages/appointments.tsx | 12 +- src/pages/call-history.tsx | 16 +- src/pages/call-recordings.tsx | 5 +- src/pages/campaign-detail.tsx | 6 +- src/pages/login.tsx | 5 + src/pages/missed-calls.tsx | 5 +- src/providers/sip-provider.tsx | 22 ++- src/state/sip-manager.ts | 9 +- 21 files changed, 462 insertions(+), 107 deletions(-) create mode 100644 src/components/modals/maint-otp-modal.tsx create mode 100644 src/hooks/use-agent-state.ts create mode 100644 src/hooks/use-maint-shortcuts.ts diff --git a/src/components/call-desk/active-call-card.tsx b/src/components/call-desk/active-call-card.tsx index 597731e..cfb3f42 100644 --- a/src/components/call-desk/active-call-card.tsx +++ b/src/components/call-desk/active-call-card.tsx @@ -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 diff --git a/src/components/call-desk/agent-status-toggle.tsx b/src/components/call-desk/agent-status-toggle.tsx index f9ee252..ced5b15 100644 --- a/src/components/call-desk/agent-status-toggle.tsx +++ b/src/components/call-desk/agent-status-toggle.tsx @@ -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 = { +const displayConfig: Record = { 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(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 (
{menuOpen && ( <>
setMenuOpen(false)} />
- {(Object.entries(statusConfig) as [AgentStatus, typeof current][]).map(([key, cfg]) => ( + {toggleOptions.map((opt) => ( ))}
diff --git a/src/components/call-desk/call-widget.tsx b/src/components/call-desk/call-widget.tsx index f46ca44..29f7174 100644 --- a/src/components/call-desk/call-widget.tsx +++ b/src/components/call-desk/call-widget.tsx @@ -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 = { - registered: 'bg-success-500', - connecting: 'bg-warning-500', - disconnected: 'bg-quaternary', - error: 'bg-error-500', -}; - -const statusLabel: Record = { - 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(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 ( -
- - {label} - Helix Phone -
- ); + 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'); diff --git a/src/components/call-desk/live-transcript.tsx b/src/components/call-desk/live-transcript.tsx index 47c247a..8034d82 100644 --- a/src/components/call-desk/live-transcript.tsx +++ b/src/components/call-desk/live-transcript.tsx @@ -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", )}> - {item.timestamp.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })} + {formatTimeFull(item.timestamp.toISOString())} {item.text}
diff --git a/src/components/call-desk/worklist-panel.tsx b/src/components/call-desk/worklist-panel.tsx index 5bacbd3..8e90125 100644 --- a/src/components/call-desk/worklist-panel.tsx +++ b/src/components/call-desk/worklist-panel.tsx @@ -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'), diff --git a/src/components/campaigns/campaign-hero.tsx b/src/components/campaigns/campaign-hero.tsx index e047d96..365c376 100644 --- a/src/components/campaigns/campaign-hero.tsx +++ b/src/components/campaigns/campaign-hero.tsx @@ -8,6 +8,7 @@ const LinkExternal01: FC<{ className?: string }> = ({ className }) => { - 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 => { diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index 5f24713..24dae4a 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -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) => {
{children}
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && }
+ !open && close()} action={activeAction} /> ); }; diff --git a/src/components/modals/maint-otp-modal.tsx b/src/components/modals/maint-otp-modal.tsx new file mode 100644 index 0000000..4c23f7d --- /dev/null +++ b/src/components/modals/maint-otp-modal.tsx @@ -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 }) => ( + +); + +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(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 ( + + + + {() => ( +
+ {/* Header */} +
+ +
+

{action.label}

+

{action.description}

+
+
+ + {/* Pin Input */} +
+ + Enter maintenance code + + + + + + + + + + + {error && ( +

{error}

+ )} +
+ + {/* Footer */} +
+ + +
+
+ )} +
+
+
+ ); +}; diff --git a/src/components/shared/global-search.tsx b/src/components/shared/global-search.tsx index 15843de..26e721b 100644 --- a/src/components/shared/global-search.tsx +++ b/src/components/shared/global-search.tsx @@ -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', diff --git a/src/hooks/use-agent-state.ts b/src/hooks/use-agent-state.ts new file mode 100644 index 0000000..2eaf288 --- /dev/null +++ b/src/hooks/use-agent-state.ts @@ -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('offline'); + const prevStateRef = useRef('offline'); + const esRef = useRef(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; +}; diff --git a/src/hooks/use-maint-shortcuts.ts b/src/hooks/use-maint-shortcuts.ts new file mode 100644 index 0000000..7c8cb97 --- /dev/null +++ b/src/hooks/use-maint-shortcuts.ts @@ -0,0 +1,71 @@ +import { useState, useEffect, useCallback } from 'react'; + +export type MaintAction = { + endpoint: string; + label: string; + description: string; +}; + +const MAINT_ACTIONS: Record = { + 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(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 }; +}; diff --git a/src/lib/format.ts b/src/lib/format.ts index d9a5e6f..4bf3f7a 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -26,10 +26,33 @@ export const formatRelativeAge = (dateStr: string): string => { 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 => 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 export const getInitials = (firstName: string, lastName: string): string => `${firstName[0] || ''}${lastName[0] || ''}`.toUpperCase(); diff --git a/src/lib/sip-client.ts b/src/lib/sip-client.ts index 4e27b67..aa55129 100644 --- a/src/lib/sip-client.ts +++ b/src/lib/sip-client.ts @@ -15,7 +15,8 @@ export class SIPClient { ) {} 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 sipId = this.config.uri.replace('sip:', '').split('@')[0]; @@ -36,22 +37,27 @@ export class SIPClient { this.ua = new JsSIP.UA(configuration); this.ua.on('connected', () => { + console.log('[SIP] WebSocket connected'); this.onConnectionChange('connected'); }); this.ua.on('disconnected', () => { + console.log('[SIP] WebSocket disconnected'); this.onConnectionChange('disconnected'); }); this.ua.on('registered', () => { + console.log('[SIP] Registered successfully'); this.onConnectionChange('registered'); }); this.ua.on('unregistered', () => { + console.log('[SIP] Unregistered'); this.onConnectionChange('disconnected'); }); this.ua.on('registrationFailed', () => { + console.error('[SIP] Registration failed'); this.onConnectionChange('error'); }); @@ -71,6 +77,8 @@ export class SIPClient { const callerNumber = this.extractCallerNumber(session, sipRequest); 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 session.on('peerconnection', (e: PeerConnectionEvent) => { const pc = e.peerconnection; @@ -85,6 +93,7 @@ export class SIPClient { }); session.on('accepted', (() => { + console.log(`[SIP] Call accepted — ucid=${ucid ?? 'none'}`); this.onCallStateChange('active', callerNumber, ucid ?? undefined); }) as CallListener); @@ -98,12 +107,14 @@ export class SIPClient { } }) 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.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.onCallStateChange('ended'); }); diff --git a/src/pages/appointments.tsx b/src/pages/appointments.tsx index f31689e..8ef7ed0 100644 --- a/src/pages/appointments.tsx +++ b/src/pages/appointments.tsx @@ -9,7 +9,7 @@ import { Table } from '@/components/application/table/table'; import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs'; import { TopBar } from '@/components/layout/top-bar'; 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'; type AppointmentRecord = { @@ -60,15 +60,9 @@ const QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast doctor { clinic { clinicName } } } } } }`; -const formatDate = (iso: string): string => { - const d = new Date(iso); - return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }); -}; +const formatDate = (iso: string): string => formatDateOnly(iso); -const formatTime = (iso: string): string => { - const d = new Date(iso); - return d.toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true }); -}; +const formatTime = (iso: string): string => formatTimeOnly(iso); export const AppointmentsPage = () => { const [appointments, setAppointments] = useState([]); diff --git a/src/pages/call-history.tsx b/src/pages/call-history.tsx index 3cd7ca3..9d6a507 100644 --- a/src/pages/call-history.tsx +++ b/src/pages/call-history.tsx @@ -108,6 +108,13 @@ export const CallHistoryPage = () => { const [search, setSearch] = useState(''); const [filter, setFilter] = useState('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 const leadNameMap = useMemo(() => { const map = new Map(); @@ -151,20 +158,19 @@ export const CallHistoryPage = () => { return result; }, [calls, filter, search, leadNameMap]); - const inboundCount = calls.filter((c) => c.callDirection === 'INBOUND').length; - const outboundCount = calls.filter((c) => c.callDirection === 'OUTBOUND').length; - const missedCount = calls.filter((c) => c.callStatus === 'MISSED').length; + const completedCount = filteredCalls.filter((c) => c.callStatus !== 'MISSED').length; + const missedCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length; return (
- +
diff --git a/src/pages/call-recordings.tsx b/src/pages/call-recordings.tsx index c118bbe..e4e0dfb 100644 --- a/src/pages/call-recordings.tsx +++ b/src/pages/call-recordings.tsx @@ -10,7 +10,7 @@ import { Table } from '@/components/application/table/table'; import { TopBar } from '@/components/layout/top-bar'; import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; import { apiClient } from '@/lib/api-client'; -import { formatPhone } from '@/lib/format'; +import { formatPhone, formatDateOnly } from '@/lib/format'; type RecordingRecord = { id: string; @@ -30,8 +30,7 @@ const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { ed recording { primaryLinkUrl primaryLinkLabel } } } } }`; -const formatDate = (iso: string): string => - new Date(iso).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }); +const formatDate = (iso: string): string => formatDateOnly(iso); const formatDuration = (sec: number | null): string => { if (!sec) return '—'; diff --git a/src/pages/campaign-detail.tsx b/src/pages/campaign-detail.tsx index 5808f49..1babf94 100644 --- a/src/pages/campaign-detail.tsx +++ b/src/pages/campaign-detail.tsx @@ -12,7 +12,7 @@ import { HealthIndicator } from '@/components/campaigns/health-indicator'; import { Button } from '@/components/base/buttons/button'; import { useCampaigns } from '@/hooks/use-campaigns'; import { useLeads } from '@/hooks/use-leads'; -import { formatCurrency } from '@/lib/format'; +import { formatCurrency, formatDateOnly } from '@/lib/format'; const detailTabs = [ { id: 'overview', label: 'Overview' }, @@ -41,9 +41,7 @@ export const CampaignDetailPage = () => { const formatDateShort = (dateStr: string | null) => { if (!dateStr) return '--'; - return new Intl.DateTimeFormat('en-IN', { month: 'short', day: 'numeric', year: 'numeric' }).format( - new Date(dateStr), - ); + return formatDateOnly(dateStr); }; return ( diff --git a/src/pages/login.tsx b/src/pages/login.tsx index a02e80e..725ceff 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -8,11 +8,14 @@ import { Button } from '@/components/base/buttons/button'; import { SocialButton } from '@/components/base/buttons/social-button'; import { Checkbox } from '@/components/base/checkbox/checkbox'; 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 = () => { const { loginWithUser } = useAuth(); const { refresh } = useData(); const navigate = useNavigate(); + const { isOpen, activeAction, close } = useMaintShortcuts(); const saved = localStorage.getItem('helix_remember'); const savedCreds = saved ? JSON.parse(saved) : null; @@ -176,6 +179,8 @@ export const LoginPage = () => { {/* Footer */} Powered by F0rty2.ai + + !open && close()} action={activeAction} />
); }; diff --git a/src/pages/missed-calls.tsx b/src/pages/missed-calls.tsx index 5df1d24..4924f0c 100644 --- a/src/pages/missed-calls.tsx +++ b/src/pages/missed-calls.tsx @@ -10,7 +10,7 @@ import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs'; import { TopBar } from '@/components/layout/top-bar'; import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; import { apiClient } from '@/lib/api-client'; -import { formatPhone } from '@/lib/format'; +import { formatPhone, formatDateTimeShort } from '@/lib/format'; type MissedCallRecord = { id: string; @@ -32,8 +32,7 @@ const QUERY = `{ calls(first: 200, filter: { startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat } } } }`; -const formatDate = (iso: string): string => - new Date(iso).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit', hour12: true }); +const formatDate = (iso: string): string => formatDateTimeShort(iso); const computeSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => { const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000)); diff --git a/src/providers/sip-provider.tsx b/src/providers/sip-provider.tsx index 6cbcb9b..f510748 100644 --- a/src/providers/sip-provider.tsx +++ b/src/providers/sip-provider.tsx @@ -104,7 +104,7 @@ export const useSip = () => { const [connectionStatus] = useAtom(sipConnectionStatusAtom); const [callState, setCallState] = useAtom(sipCallStateAtom); const [callerNumber, setCallerNumber] = useAtom(sipCallerNumberAtom); - const [callUcid] = useAtom(sipCallUcidAtom); + const [callUcid, setCallUcid] = useAtom(sipCallUcidAtom); const [isMuted, setIsMuted] = useAtom(sipIsMutedAtom); const [isOnHold, setIsOnHold] = useAtom(sipIsOnHoldAtom); const [callDuration] = useAtom(sipCallDurationAtom); @@ -116,21 +116,33 @@ export const useSip = () => { // Ozonetel outbound dial — single path for all outbound calls const dialOutbound = useCallback(async (phoneNumber: string): Promise => { + console.log(`[DIAL] Outbound dial started: phone=${phoneNumber}`); setCallState('ringing-out'); setCallerNumber(phoneNumber); setOutboundPending(true); - const safetyTimeout = setTimeout(() => setOutboundPending(false), 30000); + const safetyTimeout = setTimeout(() => { + console.warn('[DIAL] Safety timeout fired (30s) — clearing outboundPending'); + setOutboundPending(false); + }, 30000); try { - await apiClient.post('/api/ozonetel/dial', { phoneNumber }); - } catch { + const result = await apiClient.post<{ status: string; ucid?: string }>('/api/ozonetel/dial', { phoneNumber }); + 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); setOutboundPending(false); setCallState('idle'); setCallerNumber(null); throw new Error('Dial failed'); } - }, [setCallState, setCallerNumber]); + }, [setCallState, setCallerNumber, setCallUcid]); const answer = useCallback(() => getSipClient()?.answer(), []); const reject = useCallback(() => getSipClient()?.reject(), []); diff --git a/src/state/sip-manager.ts b/src/state/sip-manager.ts index 7f4baa4..be2438c 100644 --- a/src/state/sip-manager.ts +++ b/src/state/sip-manager.ts @@ -53,26 +53,24 @@ export function connectSip(config: SIPConfig): void { if (state === 'ringing-in' && outboundPending) { outboundPending = false; outboundActive = true; - console.log('[SIP] Outbound bridge detected — auto-answering'); + console.log('[SIP-MGR] Outbound bridge detected — auto-answering'); setTimeout(() => { sipClient?.answer(); setTimeout(() => stateUpdater?.setCallState('active'), 300); }, 500); - // Store UCID even for outbound bridge calls if (ucid) stateUpdater?.setCallUcid(ucid); 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); if (!outboundActive && number !== undefined) { stateUpdater?.setCallerNumber(number ?? null); } - // Store UCID if provided if (ucid) stateUpdater?.setCallUcid(ucid); - // Reset outbound flag when call ends if (state === 'ended' || state === 'failed') { outboundActive = false; outboundPending = false; @@ -84,6 +82,7 @@ export function connectSip(config: SIPConfig): void { } export function disconnectSip(): void { + console.log('[SIP-MGR] Disconnecting SIP'); sipClient?.disconnect(); sipClient = null; connected = false;