From 30b59be6041e6dc59cb950bf9195a680e4ce510a Mon Sep 17 00:00:00 2001 From: moulichand16 Date: Wed, 25 Mar 2026 16:14:15 +0530 Subject: [PATCH 01/42] added Patient info from Patient master --- .../shared/patient-profile-panel.tsx | 191 ++++++++++++++++++ src/pages/patients.tsx | 54 ++++- 2 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 src/components/shared/patient-profile-panel.tsx diff --git a/src/components/shared/patient-profile-panel.tsx b/src/components/shared/patient-profile-panel.tsx new file mode 100644 index 0000000..1d74fbd --- /dev/null +++ b/src/components/shared/patient-profile-panel.tsx @@ -0,0 +1,191 @@ +import { useEffect, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faSparkles, faUser, faCalendarCheck } from '@fortawesome/pro-duotone-svg-icons'; +import { faIcon } from '@/lib/icon-wrapper'; +import { Badge } from '@/components/base/badges/badges'; +import { apiClient } from '@/lib/api-client'; +import { formatPhone, formatShortDate } from '@/lib/format'; +import type { LeadActivity, Patient } from '@/types/entities'; + +const CalendarCheck = faIcon(faCalendarCheck); + +interface PatientProfilePanelProps { + patient: Patient | null; + activities?: LeadActivity[]; +} + +/** + * Reusable Patient/Lead 360 profile panel + * Shows comprehensive patient information including appointments, calls, and activity timeline + * Can be used with either Patient or Lead entities + */ +export const PatientProfilePanel = ({ patient, activities = [] }: PatientProfilePanelProps) => { + const [patientData, setPatientData] = useState(null); + const [loadingPatient, setLoadingPatient] = useState(false); + + // Fetch full patient data with appointments and calls + useEffect(() => { + if (!patient?.id) { + setPatientData(null); + return; + } + + setLoadingPatient(true); + apiClient.graphql<{ patients: { edges: Array<{ node: any }> } }>( + `query GetPatient($id: UUID!) { patients(filter: { id: { eq: $id } }) { edges { node { + id fullName { firstName lastName } dateOfBirth gender patientType + phones { primaryPhoneNumber } emails { primaryEmail } + appointments(first: 10, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { + id scheduledAt status doctorName department reasonForVisit appointmentType + } } } + calls(first: 10, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { + id callStatus disposition direction startedAt durationSec agentName + } } } + } } } }`, + { id: patient.id }, + { silent: true }, + ).then(data => { + setPatientData(data.patients.edges[0]?.node ?? null); + }).catch(() => setPatientData(null)) + .finally(() => setLoadingPatient(false)); + }, [patient?.id]); + + if (!patient) { + return ( +
+ +

Select a patient to see their full profile.

+
+ ); + } + + const firstName = patient.fullName?.firstName ?? ''; + const lastName = patient.fullName?.lastName ?? ''; + const fullName = `${firstName} ${lastName}`.trim() || 'Unknown'; + const phone = patient.phones?.primaryPhoneNumber; + const email = patient.emails?.primaryEmail; + + const appointments = patientData?.appointments?.edges?.map((e: any) => e.node) ?? []; + const patientCalls = patientData?.calls?.edges?.map((e: any) => e.node) ?? []; + + const patientAge = patientData?.dateOfBirth + ? Math.floor((Date.now() - new Date(patientData.dateOfBirth).getTime()) / (365.25 * 24 * 60 * 60 * 1000)) + : null; + const patientGender = patientData?.gender === 'MALE' ? 'M' : patientData?.gender === 'FEMALE' ? 'F' : null; + + // Filter activities for this patient (if Lead activities are provided) + const patientActivities = activities + .filter((a) => a.leadId === patient.id) + .sort((a, b) => new Date(b.occurredAt ?? b.createdAt ?? '').getTime() - new Date(a.occurredAt ?? a.createdAt ?? '').getTime()) + .slice(0, 10); + + return ( +
+ {/* Profile */} +
+

{fullName}

+ {phone &&

{formatPhone({ number: phone, callingCode: '+91' })}

} + {email &&

{email}

} +
+ {patientAge !== null && patientGender && ( + {patientAge}y · {patientGender} + )} + {patient.patientType && {patient.patientType}} + {patient.gender && ( + + {patient.gender.charAt(0) + patient.gender.slice(1).toLowerCase()} + + )} +
+
+ + {/* Loading state */} + {loadingPatient && ( +

Loading patient details...

+ )} + + {/* Appointments */} + {appointments.length > 0 && ( +
+

Appointments

+
+ {appointments.map((appt: any) => { + const statusColors: Record = { + COMPLETED: 'success', SCHEDULED: 'brand', CONFIRMED: 'brand', + CANCELLED: 'error', NO_SHOW: 'warning', + }; + return ( +
+ +
+
+ + {appt.doctorName ?? 'Doctor'} · {appt.department ?? ''} + + {appt.status && ( + + {appt.status.toLowerCase()} + + )} +
+

+ {appt.scheduledAt ? formatShortDate(appt.scheduledAt) : ''} + {appt.reasonForVisit ? ` — ${appt.reasonForVisit}` : ''} +

+
+
+ ); + })} +
+
+ )} + + {/* Recent calls */} + {patientCalls.length > 0 && ( +
+

Recent Calls

+
+ {patientCalls.map((call: any) => ( +
+
+ + {call.direction === 'INBOUND' ? 'Inbound' : 'Outbound'} + {call.disposition ? ` — ${call.disposition.replace(/_/g, ' ').toLowerCase()}` : ''} + + {call.startedAt ? formatShortDate(call.startedAt) : ''} +
+ ))} +
+
+ )} + + {/* Activity timeline (if activities provided) */} + {patientActivities.length > 0 && ( +
+

Activity

+
+ {patientActivities.map((a) => ( +
+
+
+

{a.summary}

+

+ {a.activityType}{a.occurredAt ? ` · ${formatShortDate(a.occurredAt)}` : ''} +

+
+
+ ))} +
+
+ )} + + {/* Empty state when no data */} + {!loadingPatient && appointments.length === 0 && patientCalls.length === 0 && patientActivities.length === 0 && ( +
+ +

No appointments or call history yet

+
+ )} +
+ ); +}; diff --git a/src/pages/patients.tsx b/src/pages/patients.tsx index aa9a688..a5338f4 100644 --- a/src/pages/patients.tsx +++ b/src/pages/patients.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react'; import { useNavigate } from 'react-router'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faUser, faMagnifyingGlass, faEye, faCommentDots, faMessageDots } from '@fortawesome/pro-duotone-svg-icons'; +import { faUser, faMagnifyingGlass, faEye, faCommentDots, faMessageDots, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons'; import { faIcon } from '@/lib/icon-wrapper'; const SearchLg = faIcon(faMagnifyingGlass); @@ -12,8 +12,10 @@ import { Input } from '@/components/base/input/input'; import { Table, TableCard } from '@/components/application/table/table'; import { TopBar } from '@/components/layout/top-bar'; import { ClickToCallButton } from '@/components/call-desk/click-to-call-button'; +import { PatientProfilePanel } from '@/components/shared/patient-profile-panel'; import { useData } from '@/providers/data-provider'; import { getInitials } from '@/lib/format'; +import { cx } from '@/utils/cx'; import type { Patient } from '@/types/entities'; const computeAge = (dateOfBirth: string | null): number | null => { @@ -58,6 +60,8 @@ export const PatientsPage = () => { const navigate = useNavigate(); const [searchQuery, setSearchQuery] = useState(''); const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all'); + const [selectedPatient, setSelectedPatient] = useState(null); + const [panelOpen, setPanelOpen] = useState(false); const filteredPatients = useMemo(() => { return patients.filter((patient) => { @@ -80,10 +84,11 @@ export const PatientsPage = () => { }, [patients, searchQuery, statusFilter]); return ( -
+
-
+
+
{ description="Manage and view patient records" contentTrailing={
+ {/* Status filter buttons */}
{(['all', 'active', 'inactive'] as const).map((status) => ( @@ -157,7 +169,17 @@ export const PatientsPage = () => { : '?'; return ( - + { + setSelectedPatient(patient); + setPanelOpen(true); + }} + > {/* Patient name + avatar */}
@@ -266,6 +288,30 @@ export const PatientsPage = () => { )} +
+ + {/* Patient Profile Panel - collapsible with smooth transition */} +
+ {panelOpen && ( +
+
+

Patient Profile

+ +
+
+ +
+
+ )} +
); From 488f524f84c85344a3804722226ad51486b07ffa Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Tue, 24 Mar 2026 22:03:48 +0530 Subject: [PATCH 02/42] 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; From 70e0f6fc3e069749b4257fecf712939506d64f61 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Wed, 25 Mar 2026 09:19:52 +0530 Subject: [PATCH 03/42] feat: call recording analysis with Deepgram diarization + AI insights - Deepgram pre-recorded API: transcription with diarization, sentiment, topics, summary - OpenAI structured insights: call outcome, patient satisfaction, coaching notes, action items, compliance flags - Slideout panel UI with audio player, speaker-labeled transcript, sentiment badge - AI pill button in recordings table between Caller and Type columns - Redis caching (7-day TTL) to avoid re-analyzing the same recording Co-Authored-By: Claude Opus 4.6 (1M context) --- .../call-desk/recording-analysis.tsx | 302 ++++++++++++++++++ src/pages/call-recordings.tsx | 41 ++- 2 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 src/components/call-desk/recording-analysis.tsx diff --git a/src/components/call-desk/recording-analysis.tsx b/src/components/call-desk/recording-analysis.tsx new file mode 100644 index 0000000..59bf5f5 --- /dev/null +++ b/src/components/call-desk/recording-analysis.tsx @@ -0,0 +1,302 @@ +import { useEffect, useRef, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faWaveformLines, faSpinner, faPlay, faPause } from '@fortawesome/pro-duotone-svg-icons'; +import { Badge } from '@/components/base/badges/badges'; +import { Button } from '@/components/base/buttons/button'; +import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu'; +import { apiClient } from '@/lib/api-client'; +import { formatPhone, formatDateOnly } from '@/lib/format'; +import { cx } from '@/utils/cx'; + +type Utterance = { + speaker: number; + start: number; + end: number; + text: string; +}; + +type Insights = { + keyTopics: string[]; + actionItems: string[]; + coachingNotes: string[]; + complianceFlags: string[]; + patientSatisfaction: string; + callOutcome: string; +}; + +type Analysis = { + transcript: Utterance[]; + summary: string | null; + sentiment: 'positive' | 'neutral' | 'negative' | 'mixed'; + sentimentScore: number; + insights: Insights; + durationSec: number; +}; + +const sentimentConfig = { + positive: { label: 'Positive', color: 'success' as const }, + neutral: { label: 'Neutral', color: 'gray' as const }, + negative: { label: 'Negative', color: 'error' as const }, + mixed: { label: 'Mixed', color: 'warning' as const }, +}; + +const formatTimestamp = (sec: number): string => { + const m = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + return `${m}:${s.toString().padStart(2, '0')}`; +}; + +const formatDuration = (sec: number | null): string => { + if (!sec) return ''; + const m = Math.floor(sec / 60); + const s = sec % 60; + return `${m}:${s.toString().padStart(2, '0')}`; +}; + +// Inline audio player for the slideout header +const SlideoutPlayer = ({ url }: { url: string }) => { + const audioRef = useRef(null); + const [playing, setPlaying] = useState(false); + + const toggle = () => { + if (!audioRef.current) return; + if (playing) { audioRef.current.pause(); } else { audioRef.current.play(); } + setPlaying(!playing); + }; + + return ( +
+ + {playing ? 'Playing...' : 'Play recording'} +
+ ); +}; + +// Insights section rendered after analysis completes +const InsightsSection = ({ label, children }: { label: string; children: React.ReactNode }) => ( +
+ {label} +
{children}
+
+); + +type RecordingAnalysisSlideoutProps = { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + recordingUrl: string; + callId: string; + agentName: string | null; + callerNumber: string | null; + direction: string | null; + startedAt: string | null; + durationSec: number | null; + disposition: string | null; +}; + +export const RecordingAnalysisSlideout = ({ + isOpen, + onOpenChange, + recordingUrl, + callId, + agentName, + callerNumber, + direction, + startedAt, + durationSec, + disposition, +}: RecordingAnalysisSlideoutProps) => { + const [analysis, setAnalysis] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const hasTriggered = useRef(false); + + // Auto-trigger analysis when the slideout opens + useEffect(() => { + if (!isOpen || hasTriggered.current) return; + hasTriggered.current = true; + + setLoading(true); + setError(null); + apiClient.post('/api/recordings/analyze', { recordingUrl, callId }) + .then((result) => setAnalysis(result)) + .catch((err: any) => setError(err.message ?? 'Analysis failed')) + .finally(() => setLoading(false)); + }, [isOpen, recordingUrl, callId]); + + const dirLabel = direction === 'INBOUND' ? 'Inbound' : 'Outbound'; + const dirColor = direction === 'INBOUND' ? 'blue' : 'brand'; + const formattedPhone = callerNumber + ? formatPhone({ number: callerNumber, callingCode: '+91' }) + : null; + + return ( + + {({ close }) => ( + <> + +
+

Call Analysis

+
+ {dirLabel} + {agentName && {agentName}} + {formattedPhone && ( + <> + - + {formattedPhone} + + )} +
+
+ {startedAt && {formatDateOnly(startedAt)}} + {durationSec != null && durationSec > 0 && {formatDuration(durationSec)}} + {disposition && ( + + {disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())} + + )} +
+
+ +
+
+
+ + + {loading && ( +
+ +

Analyzing recording...

+

Transcribing and generating insights

+
+ )} + + {error && !loading && ( +
+

{error}

+ +
+ )} + + {analysis && !loading && ( + + )} +
+ + )} +
+ ); +}; + +// Separated analysis results display for readability +const AnalysisResults = ({ analysis }: { analysis: Analysis }) => { + const sentCfg = sentimentConfig[analysis.sentiment]; + + return ( +
+ {/* Sentiment + topics */} +
+ {sentCfg.label} + {analysis.insights.keyTopics.slice(0, 4).map((topic) => ( + {topic} + ))} +
+ + {/* Summary */} + {analysis.summary && ( +
+ Summary +

{analysis.summary}

+
+ )} + + {/* Call outcome */} +
+ Call Outcome +

{analysis.insights.callOutcome}

+
+ + {/* Insights grid */} +
+ +

{analysis.insights.patientSatisfaction}

+
+ + {analysis.insights.actionItems.length > 0 && ( + +
    + {analysis.insights.actionItems.map((item, i) => ( +
  • - {item}
  • + ))} +
+
+ )} + + {analysis.insights.coachingNotes.length > 0 && ( + +
    + {analysis.insights.coachingNotes.map((note, i) => ( +
  • - {note}
  • + ))} +
+
+ )} + + {analysis.insights.complianceFlags.length > 0 && ( +
+ Compliance Flags +
    + {analysis.insights.complianceFlags.map((flag, i) => ( +
  • - {flag}
  • + ))} +
+
+ )} +
+ + {/* Transcript */} + {analysis.transcript.length > 0 && ( +
+ Transcript +
+ {analysis.transcript.map((u, i) => { + const isAgent = u.speaker === 0; + return ( +
+ {formatTimestamp(u.start)} + + {isAgent ? 'Agent' : 'Customer'} + + {u.text} +
+ ); + })} +
+
+ )} +
+ ); +}; diff --git a/src/pages/call-recordings.tsx b/src/pages/call-recordings.tsx index e4e0dfb..ed4f157 100644 --- a/src/pages/call-recordings.tsx +++ b/src/pages/call-recordings.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState, useRef } from 'react'; -import { faMagnifyingGlass, faPlay, faPause } from '@fortawesome/pro-duotone-svg-icons'; +import { faMagnifyingGlass, faPlay, faPause, faSparkles } from '@fortawesome/pro-duotone-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faIcon } from '@/lib/icon-wrapper'; @@ -9,6 +9,7 @@ import { Input } from '@/components/base/input/input'; import { Table } from '@/components/application/table/table'; import { TopBar } from '@/components/layout/top-bar'; import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; +import { RecordingAnalysisSlideout } from '@/components/call-desk/recording-analysis'; import { apiClient } from '@/lib/api-client'; import { formatPhone, formatDateOnly } from '@/lib/format'; @@ -63,6 +64,7 @@ export const CallRecordingsPage = () => { const [calls, setCalls] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); + const [slideoutCallId, setSlideoutCallId] = useState(null); useEffect(() => { apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true }) @@ -110,6 +112,7 @@ export const CallRecordingsPage = () => { + @@ -132,6 +135,21 @@ export const CallRecordingsPage = () => { ) : } + + { + e.stopPropagation(); + setSlideoutCallId(call.id); + }} + className="inline-flex items-center gap-1 rounded-full bg-brand-primary px-2.5 py-1 text-xs font-semibold text-brand-secondary hover:bg-brand-secondary hover:text-white cursor-pointer transition duration-100 ease-linear" + title="AI Analysis" + > + + AI + + {dirLabel} @@ -159,7 +177,28 @@ export const CallRecordingsPage = () => { )} +
+ + {/* Analysis slideout */} + {(() => { + const call = slideoutCallId ? filtered.find(c => c.id === slideoutCallId) : null; + if (!call?.recording?.primaryLinkUrl) return null; + return ( + { if (!open) setSlideoutCallId(null); }} + recordingUrl={call.recording.primaryLinkUrl} + callId={call.id} + agentName={call.agentName} + callerNumber={call.callerNumber?.primaryPhoneNumber ?? null} + direction={call.direction} + startedAt={call.startedAt} + durationSec={call.durationSec} + disposition={call.disposition} + /> + ); + })()}
); From daa2fbb0c2b806ec771ffeed630cd89216692b86 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Wed, 25 Mar 2026 11:51:32 +0530 Subject: [PATCH 04/42] fix: SIP driven by Agent entity, token refresh, network indicator - SIP connection only for users with Agent entity (no env var fallback) - Supervisor no longer intercepts CC agent calls - Auth controller checks Agent entity for ALL roles, not just cc-agent - Token refresh handles GraphQL UNAUTHENTICATED errors (200 with error body) - Token refresh handles sidecar 400s from expired upstream tokens - Network quality indicator in sidebar (offline/unstable/good) - Ozonetel IDLE event mapped to ready state (fixes stuck calling after canceled call) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/layout/sidebar.tsx | 23 ++++++++++++++++ src/hooks/use-network-status.ts | 46 +++++++++++++++++++++++++++++++ src/lib/api-client.ts | 32 ++++++++++++++++++++- src/providers/sip-provider.tsx | 39 ++++++++++++++------------ 4 files changed, 121 insertions(+), 19 deletions(-) create mode 100644 src/hooks/use-network-status.ts diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 82f5be0..d7ee87b 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -13,6 +13,8 @@ import { faCalendarCheck, faPhone, faUsers, + faWifi, + faWifiSlash, faArrowRightFromBracket, faTowerBroadcast, faChartLine, @@ -32,6 +34,7 @@ import { Avatar } from "@/components/base/avatar/avatar"; import { apiClient } from "@/lib/api-client"; import { notify } from "@/lib/toast"; import { useAuth } from "@/providers/auth-provider"; +import { useNetworkStatus } from "@/hooks/use-network-status"; import { sidebarCollapsedAtom } from "@/state/sidebar-state"; import { cx } from "@/utils/cx"; @@ -123,6 +126,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => { const { logout, user } = useAuth(); const navigate = useNavigate(); const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom); + const networkQuality = useNetworkStatus(); const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH; @@ -218,6 +222,25 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => { ))} + {/* Network indicator — only shows when network is degraded */} + {networkQuality !== 'good' && ( +
+ + {!collapsed && ( + {networkQuality === 'offline' ? 'No connection' : 'Unstable network'} + )} +
+ )} + {/* Account card */}
{collapsed ? ( diff --git a/src/hooks/use-network-status.ts b/src/hooks/use-network-status.ts new file mode 100644 index 0000000..e988f31 --- /dev/null +++ b/src/hooks/use-network-status.ts @@ -0,0 +1,46 @@ +import { useState, useEffect, useRef } from 'react'; + +export type NetworkQuality = 'good' | 'unstable' | 'offline'; + +export const useNetworkStatus = (): NetworkQuality => { + const [quality, setQuality] = useState(navigator.onLine ? 'good' : 'offline'); + const dropCountRef = useRef(0); + const resetTimerRef = useRef(null); + + useEffect(() => { + const handleOffline = () => { + console.log('[NETWORK] Offline'); + setQuality('offline'); + }; + + const handleOnline = () => { + console.log('[NETWORK] Back online'); + dropCountRef.current++; + + // 3+ drops in 2 minutes = unstable + if (dropCountRef.current >= 3) { + setQuality('unstable'); + } else { + setQuality('good'); + } + + // Reset drop counter after 2 minutes of stability + if (resetTimerRef.current) clearTimeout(resetTimerRef.current); + resetTimerRef.current = window.setTimeout(() => { + dropCountRef.current = 0; + if (navigator.onLine) setQuality('good'); + }, 120000); + }; + + window.addEventListener('offline', handleOffline); + window.addEventListener('online', handleOnline); + + return () => { + window.removeEventListener('offline', handleOffline); + window.removeEventListener('online', handleOnline); + if (resetTimerRef.current) clearTimeout(resetTimerRef.current); + }; + }, []); + + return quality; +}; diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 3d44bee..83ec8c4 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -88,6 +88,21 @@ const handleResponse = async (response: Response, silent = false, retryFn?: ( const json = await response.json().catch(() => null); + // Sidecar may return 400 when the underlying platform token expired — retry with refreshed token + if (!response.ok && retryFn) { + const msg = (json?.message ?? '').toLowerCase(); + if (msg.includes('agent identity') || msg.includes('token') || msg.includes('unauthenticated')) { + const refreshed = await tryRefreshToken(); + if (refreshed) { + const retryResponse = await retryFn(); + return handleResponse(retryResponse, silent); + } + clearTokens(); + if (!silent) notify.error('Session expired. Please log in again.'); + throw new AuthError(); + } + } + if (!response.ok) { const message = json?.message ?? json?.error ?? `Request failed (${response.status})`; if (!silent) notify.error(message); @@ -152,7 +167,22 @@ export const apiClient = { } } - const json = await response.json(); + let json = await response.json(); + + // Platform returns 200 with UNAUTHENTICATED error when token expires — retry with refresh + const authError = json.errors?.find((e: any) => e.extensions?.code === 'UNAUTHENTICATED'); + if (authError) { + const refreshed = await tryRefreshToken(); + if (refreshed) { + const retryResponse = await doFetch(); + json = await retryResponse.json(); + } else { + clearTokens(); + if (!options?.silent) notify.error('Session expired', 'Please log in again.'); + throw new AuthError(); + } + } + if (json.errors) { const message = json.errors[0]?.message ?? 'GraphQL error'; if (!options?.silent) notify.error('Query failed', message); diff --git a/src/providers/sip-provider.tsx b/src/providers/sip-provider.tsx index f510748..9926898 100644 --- a/src/providers/sip-provider.tsx +++ b/src/providers/sip-provider.tsx @@ -14,27 +14,25 @@ import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOu import { apiClient } from '@/lib/api-client'; import type { SIPConfig } from '@/types/sip'; -const getSipConfig = (): SIPConfig => { +// SIP config comes exclusively from the Agent entity (stored on login). +// No env var fallback — users without an Agent entity don't connect SIP. +const getSipConfig = (): SIPConfig | null => { try { const stored = localStorage.getItem('helix_agent_config'); if (stored) { const config = JSON.parse(stored); - return { - displayName: 'Helix Agent', - uri: config.sipUri, - password: config.sipPassword, - wsServer: config.sipWsServer, - stunServers: 'stun:stun.l.google.com:19302', - }; + if (config.sipUri && config.sipWsServer) { + return { + displayName: 'Helix Agent', + uri: config.sipUri, + password: config.sipPassword, + wsServer: config.sipWsServer, + stunServers: 'stun:stun.l.google.com:19302', + }; + } } } catch {} - return { - displayName: import.meta.env.VITE_SIP_DISPLAY_NAME ?? 'Helix Agent', - uri: import.meta.env.VITE_SIP_URI ?? '', - password: import.meta.env.VITE_SIP_PASSWORD ?? '', - wsServer: import.meta.env.VITE_SIP_WS_SERVER ?? '', - stunServers: 'stun:stun.l.google.com:19302', - }; + return null; }; export const SipProvider = ({ children }: PropsWithChildren) => { @@ -55,9 +53,14 @@ export const SipProvider = ({ children }: PropsWithChildren) => { }); }, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid]); - // Auto-connect SIP on mount + // Auto-connect SIP on mount — only if Agent entity has SIP config useEffect(() => { - connectSip(getSipConfig()); + const config = getSipConfig(); + if (config) { + connectSip(config); + } else { + console.log('[SIP] No agent SIP config — skipping connection'); + } }, []); // Call duration timer @@ -178,7 +181,7 @@ export const useSip = () => { isInCall: ['ringing-in', 'ringing-out', 'active'].includes(callState), ozonetelStatus: 'logged-in' as const, ozonetelError: null as string | null, - connect: () => connectSip(getSipConfig()), + connect: () => { const c = getSipConfig(); if (c) connectSip(c); }, disconnect: disconnectSip, makeCall, dialOutbound, From e6b2208077be0b79077eb7e719575ba838a764ad Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Wed, 25 Mar 2026 20:29:54 +0530 Subject: [PATCH 05/42] feat: disposition modal, persistent top bar, pagination, QA fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DispositionModal: single modal for all call endings. Dismissable (agent can resume call). Agent clicks End → modal → select reason → hangup + dispose. Caller disconnects → same modal. - One call screen: CallWidget stripped to ringing notification + auto-redirect to Call Desk. - Persistent top bar in AppShell: agent status toggle + network indicator on all pages. - Network indicator always visible (Connected/Unstable/No connection). - Pagination: Untitled UI PaginationCardDefault on Call History + Appointments (20/page). - Pinned table headers/footers: sticky column headers, scrollable body, pinned pagination. Applied to Call Desk worklist, Call History, Appointments, Call Recordings, Missed Calls. - "Patient" → "Caller" column label in Call History. - Offline → Ready toggle enabled. - Profile status dot reflects Ozonetel state. - NavAccountCard: popover placement top, View Profile + Account Settings restored. - WIP pages for /profile and /account-settings. - Enquiry form PHONE_INQUIRY → PHONE enum fix. - Force Ready / View Profile / Account Settings removed then restored properly. Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 151 ++++++ package.json | 1 + .../base-components/nav-account-card.tsx | 22 +- .../application/pagination/pagination.tsx | 146 +----- src/components/application/table/table.tsx | 10 +- src/components/call-desk/active-call-card.tsx | 287 +++++------ .../call-desk/agent-status-toggle.tsx | 2 +- src/components/call-desk/call-widget.tsx | 478 ++---------------- .../call-desk/disposition-modal.tsx | 160 ++++++ src/components/call-desk/enquiry-form.tsx | 2 +- src/components/call-desk/worklist-panel.tsx | 10 +- src/components/layout/app-shell.tsx | 33 +- src/components/layout/sidebar.tsx | 47 +- src/main.tsx | 4 + src/pages/account-settings.tsx | 14 + src/pages/appointments.tsx | 22 +- src/pages/call-desk.tsx | 6 +- src/pages/call-history.tsx | 34 +- src/pages/call-recordings.tsx | 4 +- src/pages/missed-calls.tsx | 4 +- src/pages/profile.tsx | 24 + 21 files changed, 645 insertions(+), 816 deletions(-) create mode 100644 src/components/call-desk/disposition-modal.tsx create mode 100644 src/pages/account-settings.tsx create mode 100644 src/pages/profile.tsx diff --git a/package-lock.json b/package-lock.json index a5fe3b1..d472371 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "jotai": "^2.18.1", "jssip": "^3.13.6", "motion": "^12.29.0", + "pptxgenjs": "^4.0.1", "qr-code-styling": "^1.9.2", "react": "^19.2.3", "react-aria": "^3.46.0", @@ -4115,6 +4116,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "http://localhost:4873/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "http://localhost:4873/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4646,6 +4653,12 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/https": { + "version": "1.0.0", + "resolved": "http://localhost:4873/https/-/https-1.0.0.tgz", + "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==", + "license": "ISC" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "http://localhost:4873/ignore/-/ignore-5.3.2.tgz", @@ -4657,6 +4670,27 @@ "node": ">= 4" } }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "http://localhost:4873/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "http://localhost:4873/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "http://localhost:4873/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -4668,6 +4702,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "http://localhost:4873/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/input-otp": { "version": "1.4.2", "resolved": "http://localhost:4873/input-otp/-/input-otp-1.4.2.tgz", @@ -4715,6 +4755,12 @@ "node": ">=0.10.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "http://localhost:4873/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "http://localhost:4873/isexe/-/isexe-2.0.0.tgz", @@ -4823,6 +4869,18 @@ "sdp-transform": "^2.14.1" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "http://localhost:4873/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "http://localhost:4873/keyv/-/keyv-4.5.4.tgz", @@ -4849,6 +4907,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "http://localhost:4873/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "http://localhost:4873/lightningcss/-/lightningcss-1.31.1.tgz", @@ -5272,6 +5339,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "http://localhost:4873/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "http://localhost:4873/path-exists/-/path-exists-4.0.0.tgz", @@ -5353,6 +5426,33 @@ "node": ">=4" } }, + "node_modules/pptxgenjs": { + "version": "4.0.1", + "resolved": "http://localhost:4873/pptxgenjs/-/pptxgenjs-4.0.1.tgz", + "integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==", + "license": "MIT", + "dependencies": { + "@types/node": "^22.8.1", + "https": "^1.0.0", + "image-size": "^1.2.1", + "jszip": "^3.10.1" + } + }, + "node_modules/pptxgenjs/node_modules/@types/node": { + "version": "22.19.15", + "resolved": "http://localhost:4873/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/pptxgenjs/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "http://localhost:4873/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "http://localhost:4873/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5467,6 +5567,12 @@ } } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "http://localhost:4873/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "http://localhost:4873/punycode/-/punycode-2.3.1.tgz", @@ -5496,6 +5602,15 @@ "integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==", "license": "MIT" }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "http://localhost:4873/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "http://localhost:4873/react/-/react-19.2.4.tgz", @@ -5681,6 +5796,21 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "http://localhost:4873/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "http://localhost:4873/rollup/-/rollup-4.59.0.tgz", @@ -5725,6 +5855,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "http://localhost:4873/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "http://localhost:4873/scheduler/-/scheduler-0.27.0.tgz", @@ -5759,6 +5895,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "http://localhost:4873/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "http://localhost:4873/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5837,6 +5979,15 @@ "node": ">=0.10.0" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "http://localhost:4873/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "http://localhost:4873/tailwind-merge/-/tailwind-merge-3.5.0.tgz", diff --git a/package.json b/package.json index d9177c4..f4daec1 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "jotai": "^2.18.1", "jssip": "^3.13.6", "motion": "^12.29.0", + "pptxgenjs": "^4.0.1", "qr-code-styling": "^1.9.2", "react": "^19.2.3", "react-aria": "^3.46.0", diff --git a/src/components/application/app-navigation/base-components/nav-account-card.tsx b/src/components/application/app-navigation/base-components/nav-account-card.tsx index 9f84595..cbfb5a7 100644 --- a/src/components/application/app-navigation/base-components/nav-account-card.tsx +++ b/src/components/application/app-navigation/base-components/nav-account-card.tsx @@ -2,12 +2,11 @@ import type { FC, HTMLAttributes } from "react"; import { useCallback, useEffect, useRef } from "react"; import type { Placement } from "@react-types/overlays"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faUser, faGear, faArrowRightFromBracket, faPhoneVolume, faSort } from "@fortawesome/pro-duotone-svg-icons"; +import { faArrowRightFromBracket, faSort, faUser, faGear } from "@fortawesome/pro-duotone-svg-icons"; const IconUser: FC<{ className?: string }> = ({ className }) => ; const IconSettings: FC<{ className?: string }> = ({ className }) => ; const IconLogout: FC<{ className?: string }> = ({ className }) => ; -const IconForceReady: FC<{ className?: string }> = ({ className }) => ; import { useFocusManager } from "react-aria"; import type { DialogProps as AriaDialogProps } from "react-aria-components"; import { Button as AriaButton, Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components"; @@ -32,9 +31,10 @@ type NavAccountType = { export const NavAccountMenu = ({ className, onSignOut, - onForceReady, + onViewProfile, + onAccountSettings, ...dialogProps -}: AriaDialogProps & { className?: string; accounts?: NavAccountType[]; selectedAccountId?: string; onSignOut?: () => void; onForceReady?: () => void }) => { +}: AriaDialogProps & { className?: string; accounts?: NavAccountType[]; selectedAccountId?: string; onSignOut?: () => void; onViewProfile?: () => void; onAccountSettings?: () => void }) => { const focusManager = useFocusManager(); const dialogRef = useRef(null); @@ -75,12 +75,10 @@ export const NavAccountMenu = ({ <>
- - - { close(); onForceReady?.(); }} /> + { close(); onViewProfile?.(); }} /> + { close(); onAccountSettings?.(); }} />
-
{ close(); onSignOut?.(); }} />
@@ -126,13 +124,15 @@ export const NavAccountCard = ({ selectedAccountId, items = [], onSignOut, - onForceReady, + onViewProfile, + onAccountSettings, }: { popoverPlacement?: Placement; selectedAccountId?: string; items?: NavAccountType[]; onSignOut?: () => void; - onForceReady?: () => void; + onViewProfile?: () => void; + onAccountSettings?: () => void; }) => { const triggerRef = useRef(null); const isDesktop = useBreakpoint("lg"); @@ -173,7 +173,7 @@ export const NavAccountCard = ({ ) } > - +
diff --git a/src/components/application/pagination/pagination.tsx b/src/components/application/pagination/pagination.tsx index d7dcd84..561bf78 100644 --- a/src/components/application/pagination/pagination.tsx +++ b/src/components/application/pagination/pagination.tsx @@ -1,11 +1,10 @@ import type { FC } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faArrowLeft, faArrowRight } from "@fortawesome/pro-duotone-svg-icons"; +import { Button } from "@/components/base/buttons/button"; const ArrowLeft: FC<{ className?: string }> = ({ className }) => ; const ArrowRight: FC<{ className?: string }> = ({ className }) => ; -import { ButtonGroup, ButtonGroupItem } from "@/components/base/button-group/button-group"; -import { Button } from "@/components/base/buttons/button"; import { useBreakpoint } from "@/hooks/use-breakpoint"; import { cx } from "@/utils/cx"; import type { PaginationRootProps } from "./pagination-base"; @@ -23,7 +22,7 @@ const PaginationItem = ({ value, rounded, isCurrent }: { value: number; rounded? isCurrent={isCurrent} className={({ isSelected }) => cx( - "flex size-10 cursor-pointer items-center justify-center p-3 text-sm font-medium text-quaternary outline-focus-ring transition duration-100 ease-linear hover:bg-primary_hover hover:text-secondary focus-visible:z-10 focus-visible:bg-primary_hover focus-visible:outline-2 focus-visible:outline-offset-2", + "flex size-9 cursor-pointer items-center justify-center p-3 text-sm font-medium text-quaternary outline-focus-ring transition duration-100 ease-linear hover:bg-primary_hover hover:text-secondary focus-visible:z-10 focus-visible:bg-primary_hover focus-visible:outline-2 focus-visible:outline-offset-2", rounded ? "rounded-full" : "rounded-lg", isSelected && "bg-primary_hover text-secondary", ) @@ -34,43 +33,6 @@ const PaginationItem = ({ value, rounded, isCurrent }: { value: number; rounded? ); }; -interface MobilePaginationProps { - /** The current page. */ - page?: number; - /** The total number of pages. */ - total?: number; - /** The class name of the pagination component. */ - className?: string; - /** The function to call when the page changes. */ - onPageChange?: (page: number) => void; -} - -const MobilePagination = ({ page = 1, total = 10, className, onPageChange }: MobilePaginationProps) => { - return ( - - ); -}; - export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className, ...props }: PaginationProps) => { const isDesktop = useBreakpoint("md"); @@ -84,7 +46,7 @@ export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className
@@ -103,7 +65,7 @@ export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className page.type === "page" ? ( ) : ( - + ), @@ -159,7 +121,7 @@ export const PaginationPageMinimalCenter = ({ rounded, page = 1, total = 10, cla page.type === "page" ? ( ) : ( - + ), @@ -210,7 +172,7 @@ export const PaginationCardDefault = ({ rounded, page = 1, total = 10, ...props page.type === "page" ? ( ) : ( - + ), @@ -235,99 +197,3 @@ export const PaginationCardDefault = ({ rounded, page = 1, total = 10, ...props ); }; -interface PaginationCardMinimalProps { - /** The current page. */ - page?: number; - /** The total number of pages. */ - total?: number; - /** The alignment of the pagination. */ - align?: "left" | "center" | "right"; - /** The class name of the pagination component. */ - className?: string; - /** The function to call when the page changes. */ - onPageChange?: (page: number) => void; -} - -export const PaginationCardMinimal = ({ page = 1, total = 10, align = "left", onPageChange, className }: PaginationCardMinimalProps) => { - return ( -
- - - -
- ); -}; - -interface PaginationButtonGroupProps extends Partial> { - /** The alignment of the pagination. */ - align?: "left" | "center" | "right"; -} - -export const PaginationButtonGroup = ({ align = "left", page = 1, total = 10, ...props }: PaginationButtonGroupProps) => { - const isDesktop = useBreakpoint("md"); - - return ( -
- - - {({ pages }) => ( - - - {isDesktop ? "Previous" : undefined} - - - {pages.map((page, index) => - page.type === "page" ? ( - - - {page.value} - - - ) : ( - - - … - - - ), - )} - - - {isDesktop ? "Next" : undefined} - - - )} - - -
- ); -}; diff --git a/src/components/application/table/table.tsx b/src/components/application/table/table.tsx index ed63d01..5177684 100644 --- a/src/components/application/table/table.tsx +++ b/src/components/application/table/table.tsx @@ -55,7 +55,7 @@ const TableContext = createContext<{ size: "sm" | "md" }>({ size: "md" }); const TableCardRoot = ({ children, className, size = "md", ...props }: HTMLAttributes & { size?: "sm" | "md" }) => { return ( -
+
{children}
@@ -81,7 +81,7 @@ const TableCardHeader = ({ title, badge, description, contentTrailing, className return (
{ return ( -
- cx("w-full overflow-x-hidden", typeof className === "function" ? className(state) : className)} {...props} /> +
+ cx("w-full", typeof className === "function" ? className(state) : className)} {...props} />
); @@ -138,7 +138,7 @@ const TableHeader = ({ columns, children, bordered = true, cla {...props} className={(state) => cx( - "relative bg-secondary", + "relative bg-secondary sticky top-0 z-10", size === "sm" ? "h-9" : "h-11", // Row border—using an "after" pseudo-element to avoid the border taking up space. diff --git a/src/components/call-desk/active-call-card.tsx b/src/components/call-desk/active-call-card.tsx index cfb3f42..5d88df2 100644 --- a/src/components/call-desk/active-call-card.tsx +++ b/src/components/call-desk/active-call-card.tsx @@ -11,7 +11,7 @@ import { useSetAtom } from 'jotai'; import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/sip-state'; import { setOutboundPending } from '@/state/sip-manager'; import { useSip } from '@/providers/sip-provider'; -import { DispositionForm } from './disposition-form'; +import { DispositionModal } from './disposition-modal'; import { AppointmentForm } from './appointment-form'; import { TransferDialog } from './transfer-dialog'; import { EnquiryForm } from './enquiry-form'; @@ -21,8 +21,6 @@ import { cx } from '@/utils/cx'; import { notify } from '@/lib/toast'; import type { Lead, CallDisposition } from '@/types/entities'; -type PostCallStage = 'disposition' | 'appointment' | 'follow-up' | 'done'; - interface ActiveCallCardProps { lead: Lead | null; callerPhone: string; @@ -41,22 +39,28 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete const setCallState = useSetAtom(sipCallStateAtom); const setCallerNumber = useSetAtom(sipCallerNumberAtom); const setCallUcid = useSetAtom(sipCallUcidAtom); - const [postCallStage, setPostCallStage] = useState(null); const [appointmentOpen, setAppointmentOpen] = useState(false); - const [appointmentBookedDuringCall, setAppointmentBookedDuringCall] = useState(false); const [transferOpen, setTransferOpen] = useState(false); const [recordingPaused, setRecordingPaused] = useState(false); const [enquiryOpen, setEnquiryOpen] = useState(false); - // Capture direction at mount — survives through disposition stage + const [dispositionOpen, setDispositionOpen] = useState(false); + const [callerDisconnected, setCallerDisconnected] = useState(false); + const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND'); - // 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 + // Detect caller disconnect: call was active and ended without agent pressing End + useEffect(() => { + if (wasAnsweredRef.current && !dispositionOpen && (callState === 'ended' || callState === 'failed')) { + setCallerDisconnected(true); + setDispositionOpen(true); + } + }, [callState, dispositionOpen]); + const firstName = lead?.contactName?.firstName ?? ''; const lastName = lead?.contactName?.lastName ?? ''; const fullName = `${firstName} ${lastName}`.trim(); @@ -64,8 +68,12 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete const phoneDisplay = phone ? formatPhone(phone) : callerPhone || 'Unknown'; const handleDisposition = async (disposition: CallDisposition, notes: string) => { + // Hangup if still connected + if (callState === 'active' || callState === 'ringing-out' || callState === 'ringing-in') { + hangup(); + } - // Submit disposition to sidecar — handles Ozonetel ACW release + // Submit disposition to sidecar if (callUcid) { const disposePayload = { ucid: callUcid, @@ -85,7 +93,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete console.warn('[DISPOSE] No callUcid — skipping disposition'); } - // Side effects per disposition type + // Side effects if (disposition === 'FOLLOW_UP_SCHEDULED') { try { await apiClient.graphql(`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`, { @@ -104,7 +112,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete } } - // Disposition is the last step — return to worklist immediately notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`); handleReset(); }; @@ -112,13 +119,11 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete const handleAppointmentSaved = () => { setAppointmentOpen(false); notify.success('Appointment Booked', 'Payment link will be sent to the patient'); - if (callState === 'active') { - setAppointmentBookedDuringCall(true); - } }; const handleReset = () => { - setPostCallStage(null); + setDispositionOpen(false); + setCallerDisconnected(false); setCallState('idle'); setCallerNumber(null); setCallUcid(null); @@ -126,7 +131,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete onCallComplete?.(); }; - // Outbound ringing — agent initiated the call + // Outbound ringing if (callState === 'ringing-out') { return (
@@ -145,7 +150,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
@@ -177,8 +182,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete ); } - // Skip disposition for unanswered calls (ringing-in → ended without ever reaching active) - if (!wasAnsweredRef.current && postCallStage === null && (callState === 'ended' || callState === 'failed')) { + // Unanswered call (ringing → ended without ever reaching active) + if (!wasAnsweredRef.current && (callState === 'ended' || callState === 'failed')) { return (
@@ -191,149 +196,133 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete ); } - // Post-call flow takes priority over active state (handles race between hangup + SIP ended event) - if (postCallStage !== null || callState === 'ended' || callState === 'failed') { - // Disposition form + enquiry access + // Active call + if (callState === 'active' || dispositionOpen) { + wasAnsweredRef.current = true; return ( <> -
-
-
-
- +
+
+
+
+
-

Call Ended — {fullName || phoneDisplay}

-

{formatDuration(callDuration)} · Log this call

+

{fullName || phoneDisplay}

+ {fullName &&

{phoneDisplay}

}
- + {formatDuration(callDuration)}
- + + {/* Call controls */} +
+ + + + +
+ + + + + + +
+ + {/* Transfer dialog */} + {transferOpen && callUcid && ( + setTransferOpen(false)} + onTransferred={() => { + setTransferOpen(false); + setDispositionOpen(true); + }} + /> + )} + + {/* Appointment form */} + + + {/* Enquiry form */} + { + setEnquiryOpen(false); + notify.success('Enquiry Logged'); + }} + />
- { - setEnquiryOpen(false); - notify.success('Enquiry Logged'); + + {/* Disposition Modal — the ONLY path to end a call */} + { + // Agent wants to continue the call — close modal, call stays active + if (!callerDisconnected) { + setDispositionOpen(false); + } else { + // Caller already disconnected — dismiss goes to worklist + handleReset(); + } }} /> ); } - // Active call - if (callState === 'active') { - wasAnsweredRef.current = true; - return ( -
-
-
-
- -
-
-

{fullName || phoneDisplay}

- {fullName &&

{phoneDisplay}

} -
-
- {formatDuration(callDuration)} -
-
- {/* Icon-only toggles */} - - - - -
- - {/* Text+Icon primary actions */} - - - - -
- - {/* Transfer dialog */} - {transferOpen && callUcid && ( - setTransferOpen(false)} - onTransferred={() => { - setTransferOpen(false); - hangup(); - setPostCallStage('disposition'); - }} - /> - )} - - {/* Appointment form accessible during call */} - - - {/* Enquiry form */} - { - setEnquiryOpen(false); - notify.success('Enquiry Logged'); - }} - /> -
- ); - } - return null; }; diff --git a/src/components/call-desk/agent-status-toggle.tsx b/src/components/call-desk/agent-status-toggle.tsx index ced5b15..a18420a 100644 --- a/src/components/call-desk/agent-status-toggle.tsx +++ b/src/components/call-desk/agent-status-toggle.tsx @@ -74,7 +74,7 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu } const current = displayConfig[ozonetelState] ?? displayConfig.offline; - const canToggle = ozonetelState === 'ready' || ozonetelState === 'break' || ozonetelState === 'training'; + const canToggle = ozonetelState === 'ready' || ozonetelState === 'break' || ozonetelState === 'training' || ozonetelState === 'offline'; return (
diff --git a/src/components/call-desk/call-widget.tsx b/src/components/call-desk/call-widget.tsx index 29f7174..51df7b7 100644 --- a/src/components/call-desk/call-widget.tsx +++ b/src/components/call-desk/call-widget.tsx @@ -1,174 +1,41 @@ -import { useState, useEffect, useRef } from 'react'; -import { - faPhone, - faPhoneArrowDown, - faPhoneArrowUp, - faPhoneHangup, - faPhoneXmark, - faMicrophoneSlash, - faMicrophone, - faPause, - faCircleCheck, - faFloppyDisk, - faCalendarPlus, -} from '@fortawesome/pro-duotone-svg-icons'; +import { useEffect } from 'react'; +import { useNavigate, useLocation } from 'react-router'; +import { faPhone, faPhoneArrowDown, faPhoneXmark, faCircleCheck } from '@fortawesome/pro-duotone-svg-icons'; import { faIcon } from '@/lib/icon-wrapper'; const Phone01 = faIcon(faPhone); const PhoneIncoming01 = faIcon(faPhoneArrowDown); -const PhoneOutgoing01 = faIcon(faPhoneArrowUp); -const PhoneHangUp = faIcon(faPhoneHangup); const PhoneX = faIcon(faPhoneXmark); -const MicrophoneOff01 = faIcon(faMicrophoneSlash); -const Microphone01 = faIcon(faMicrophone); -const PauseCircle = faIcon(faPause); const CheckCircle = faIcon(faCircleCheck); -const Save01 = faIcon(faFloppyDisk); -const CalendarPlus02 = faIcon(faCalendarPlus); import { Button } from '@/components/base/buttons/button'; -import { TextArea } from '@/components/base/textarea/textarea'; -import { AppointmentForm } from '@/components/call-desk/appointment-form'; import { useSetAtom } from 'jotai'; import { sipCallStateAtom } from '@/state/sip-state'; import { useSip } from '@/providers/sip-provider'; -import { useAuth } from '@/providers/auth-provider'; import { cx } from '@/utils/cx'; -import type { CallDisposition } from '@/types/entities'; const formatDuration = (seconds: number): string => { - const m = Math.floor(seconds / 60) - .toString() - .padStart(2, '0'); + const m = Math.floor(seconds / 60).toString().padStart(2, '0'); const s = (seconds % 60).toString().padStart(2, '0'); return `${m}:${s}`; }; -const dispositionOptions: Array<{ - value: CallDisposition; - label: string; - activeClass: string; - defaultClass: string; -}> = [ - { - value: 'APPOINTMENT_BOOKED', - label: 'Appt Booked', - activeClass: 'bg-success-solid text-white ring-transparent', - defaultClass: 'bg-success-primary text-success-primary border-success', - }, - { - value: 'FOLLOW_UP_SCHEDULED', - label: 'Follow-up', - activeClass: 'bg-brand-solid text-white ring-transparent', - defaultClass: 'bg-brand-primary text-brand-secondary border-brand', - }, - { - value: 'INFO_PROVIDED', - label: 'Info Given', - activeClass: 'bg-utility-blue-light-600 text-white ring-transparent', - defaultClass: 'bg-utility-blue-light-50 text-utility-blue-light-700 border-utility-blue-light-200', - }, - { - value: 'NO_ANSWER', - label: 'No Answer', - activeClass: 'bg-warning-solid text-white ring-transparent', - defaultClass: 'bg-warning-primary text-warning-primary border-warning', - }, - { - value: 'WRONG_NUMBER', - label: 'Wrong #', - activeClass: 'bg-secondary-solid text-white ring-transparent', - defaultClass: 'bg-secondary text-secondary border-secondary', - }, - { - value: 'CALLBACK_REQUESTED', - label: 'Not Interested', - activeClass: 'bg-error-solid text-white ring-transparent', - defaultClass: 'bg-error-primary text-error-primary border-error', - }, -]; - +// CallWidget is a lightweight floating notification for calls outside the Call Desk. +// It only handles: ringing (answer/decline) + auto-redirect to Call Desk. +// All active call management (mute, hold, end, disposition) happens on the Call Desk via ActiveCallCard. export const CallWidget = () => { - const { - callState, - callerNumber, - isMuted, - isOnHold, - callDuration, - answer, - reject, - hangup, - toggleMute, - toggleHold, - } = useSip(); - const { user } = useAuth(); + const { callState, callerNumber, callDuration, answer, reject } = useSip(); const setCallState = useSetAtom(sipCallStateAtom); + const navigate = useNavigate(); + const { pathname } = useLocation(); - const [disposition, setDisposition] = useState(null); - const [notes, setNotes] = useState(''); - const [lastDuration, setLastDuration] = useState(0); - const [matchedLead, setMatchedLead] = useState(null); - const [leadActivities, setLeadActivities] = useState([]); - const [isSaving, setIsSaving] = useState(false); - const [isAppointmentOpen, setIsAppointmentOpen] = useState(false); - const callStartTimeRef = useRef(null); - - // Capture duration right before call ends + // Auto-navigate to Call Desk when a call becomes active or outbound ringing starts useEffect(() => { - if (callState === 'active' && callDuration > 0) { - setLastDuration(callDuration); + if (pathname === '/call-desk') return; + if (callState === 'active' || callState === 'ringing-out') { + console.log(`[CALL-WIDGET] Redirecting to Call Desk (state=${callState})`); + navigate('/call-desk'); } - }, [callState, callDuration]); - - // Track call start time - useEffect(() => { - if (callState === 'active' && !callStartTimeRef.current) { - callStartTimeRef.current = new Date().toISOString(); - } - if (callState === 'idle') { - callStartTimeRef.current = null; - } - }, [callState]); - - // Look up caller when call becomes active - useEffect(() => { - if (callState === 'ringing-in' && callerNumber && callerNumber !== 'Unknown') { - const lookup = async () => { - try { - const { apiClient } = await import('@/lib/api-client'); - const token = apiClient.getStoredToken(); - if (!token) return; - - const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; - const res = await fetch(`${API_URL}/api/call/lookup`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify({ phoneNumber: callerNumber }), - }); - const data = await res.json(); - if (data.matched && data.lead) { - setMatchedLead(data.lead); - setLeadActivities(data.activities ?? []); - } - } catch (err) { - console.warn('Lead lookup failed:', err); - } - }; - lookup(); - } - }, [callState, callerNumber]); - - // Reset state when returning to idle - useEffect(() => { - if (callState === 'idle') { - setDisposition(null); - setNotes(''); - setMatchedLead(null); - setLeadActivities([]); - } - }, [callState]); + }, [callState, pathname, navigate]); // Auto-dismiss ended/failed state after 3 seconds useEffect(() => { @@ -181,127 +48,35 @@ export const CallWidget = () => { } }, [callState, setCallState]); - const handleSaveAndClose = async () => { - if (!disposition) return; - console.log(`[CALL-WIDGET] Save & Close: disposition=${disposition} lead=${matchedLead?.id ?? 'none'}`); - setIsSaving(true); - - try { - const { apiClient } = await import('@/lib/api-client'); - - // 1. Create Call record on platform - await apiClient.graphql( - `mutation CreateCall($data: CallCreateInput!) { - createCall(data: $data) { id } - }`, - { - data: { - callDirection: 'INBOUND', - callStatus: 'COMPLETED', - agentName: user.name, - startedAt: callStartTimeRef.current, - endedAt: new Date().toISOString(), - durationSeconds: callDuration, - disposition, - callNotes: notes || null, - leadId: matchedLead?.id ?? null, - }, - }, - ).catch(err => console.warn('Failed to create call record:', err)); - - // 2. Update lead status if matched - if (matchedLead?.id) { - const statusMap: Partial> = { - APPOINTMENT_BOOKED: 'APPOINTMENT_SET', - FOLLOW_UP_SCHEDULED: 'CONTACTED', - INFO_PROVIDED: 'CONTACTED', - NO_ANSWER: 'CONTACTED', - WRONG_NUMBER: 'LOST', - CALLBACK_REQUESTED: 'CONTACTED', - NOT_INTERESTED: 'LOST', - }; - const newStatus = statusMap[disposition]; - if (newStatus) { - await apiClient.graphql( - `mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) { - updateLead(id: $id, data: $data) { id } - }`, - { - id: matchedLead.id, - data: { - leadStatus: newStatus, - lastContactedAt: new Date().toISOString(), - }, - }, - ).catch(err => console.warn('Failed to update lead:', err)); - } - - // 3. Create lead activity - await apiClient.graphql( - `mutation CreateLeadActivity($data: LeadActivityCreateInput!) { - createLeadActivity(data: $data) { id } - }`, - { - data: { - activityType: 'CALL_RECEIVED', - summary: `Inbound call — ${disposition.replace(/_/g, ' ')}`, - occurredAt: new Date().toISOString(), - performedBy: user.name, - channel: 'PHONE', - durationSeconds: callDuration, - leadId: matchedLead.id, - }, - }, - ).catch(err => console.warn('Failed to create activity:', err)); - } - } catch (err) { - console.error('Save failed:', err); - } - - setIsSaving(false); - hangup(); - setDisposition(null); - setNotes(''); - }; - - // Log state changes for observability + // Log state changes useEffect(() => { if (callState !== 'idle') { console.log(`[CALL-WIDGET] State: ${callState} | caller=${callerNumber ?? 'none'}`); } }, [callState, callerNumber]); - // Idle: nothing to show — call desk has its own status toggle - if (callState === 'idle') { - return null; - } + if (callState === 'idle') return null; - // Ringing inbound + // Ringing inbound — answer redirects to Call Desk if (callState === 'ringing-in') { return ( -
+
-
- - Incoming Call - + Incoming Call {callerNumber ?? 'Unknown'}
-
- -
- ); - } - - // Active call (full widget) - if (callState === 'active') { - return ( -
- {/* Header */} -
-
- - Active Call -
- - {formatDuration(callDuration)} - -
- - {/* Caller info */} -
- - {matchedLead?.contactName - ? `${matchedLead.contactName.firstName ?? ''} ${matchedLead.contactName.lastName ?? ''}`.trim() - : callerNumber ?? 'Unknown'} - - {matchedLead && ( - {callerNumber} - )} -
- - {/* AI Summary */} - {matchedLead?.aiSummary && ( -
-
AI Insight
-

{matchedLead.aiSummary}

- {matchedLead.aiSuggestedAction && ( - - {matchedLead.aiSuggestedAction} - - )} -
- )} - - {/* Recent activity */} - {leadActivities.length > 0 && ( -
-
Recent Activity
- {leadActivities.slice(0, 3).map((a: any, i: number) => ( -
- {a.activityType?.replace(/_/g, ' ')}: {a.summary} -
- ))} -
- )} - - {/* Call controls */} -
- - - -
- - {/* Book Appointment */} - - - { - setIsAppointmentOpen(false); - setDisposition('APPOINTMENT_BOOKED'); - }} - /> - - {/* Divider */} -
- - {/* Disposition */} -
- Disposition -
- {dispositionOptions.map((opt) => { - const isSelected = disposition === opt.value; - return ( - - ); - })} -
- -