From df08bcfc19de438b07a0ee12bfb9e811ea0a9c9d Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Thu, 16 Apr 2026 18:33:17 +0530 Subject: [PATCH] feat: SSE-driven worklist + agent call history split + remove SOURCE column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worklist: - SSE stream replaces 30s poll — EventSource on /api/supervisor/worklist/stream triggers immediate fetchWorklist() on missed-call events - Toast notification: 'Missed Call — {name} — needs callback' - No polling fallback — SSE is the source of truth Call History split by role: - Agent: 'My Call History' — own calls only (matched by agent relation or chain-parsed agentName), missed calls excluded (they belong on the Call Desk queue), no Agent/Recording/SLA columns, phone clickable via PhoneActionCell instead of separate Call button - Supervisor: 'Call History' — all calls, Agent + Recording columns visible Worklist panel: - SOURCE/BRANCH column removed from display (data stays on row) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/call-desk/worklist-panel.tsx | 43 +----- src/hooks/use-worklist.ts | 28 +++- src/pages/call-history.tsx | 149 +++++++++----------- 3 files changed, 94 insertions(+), 126 deletions(-) diff --git a/src/components/call-desk/worklist-panel.tsx b/src/components/call-desk/worklist-panel.tsx index c53ac2a..55d82a4 100644 --- a/src/components/call-desk/worklist-panel.tsx +++ b/src/components/call-desk/worklist-panel.tsx @@ -178,34 +178,9 @@ const formatTimeAgo = (dateStr: string): string => { const formatDisposition = (disposition: string): string => disposition.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); -const formatSource = (source: string): string => { - const map: Record = { - FACEBOOK_AD: 'Facebook', - GOOGLE_AD: 'Google', - WALK_IN: 'Walk-in', - REFERRAL: 'Referral', - WEBSITE: 'Website', - PHONE_INQUIRY: 'Phone', - PHONE: 'Phone', - OTHER: 'Other', - }; - return map[source] ?? source.replace(/_/g, ' '); -}; - -// Resolve a DID (e.g. "918041763265") to a friendly branch/campaign name. -// The DID is the phone number the caller dialed — it identifies which -// hospital branch or campaign the call came through. Falls back to the -// last 10 digits if no mapping is found. -const formatDid = (did: string): string => { - if (!did) return '—'; - // Known DIDs — loaded from the sidecar theme tokens at boot (via - // the ThemeTokenProvider). For now, hardcode nothing — strip country - // code and show the DID as a short number so it's at least readable. - const digits = did.replace(/\D/g, ''); - // Strip leading 91 (India) for display - const short = digits.length > 10 && digits.startsWith('91') ? digits.slice(2) : digits; - return short; -}; +// formatSource + formatDid kept for reference but no longer rendered +// in the table — SOURCE/BRANCH column removed from display per user +// request. Data stays on the row for future use. const IconInbound = faIcon(faPhoneArrowDown); const IconOutbound = faIcon(faPhoneArrowUp); @@ -239,7 +214,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea // Branch column: prefer the campaign name (e.g. "Cervical Cancer // Screening Drive") over the raw DID. Falls back to formatted DID // for organic calls with no campaign. - source: call.campaign?.campaignName ?? (call.callSourceNumber ? formatDid(call.callSourceNumber) : null), + source: call.campaign?.campaignName ?? call.callSourceNumber ?? null, lastDisposition: call.disposition ?? null, missedCallId: call.id, }); @@ -497,7 +472,6 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect - @@ -578,15 +552,6 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect No phone )} - - {row.source ? ( - - {formatSource(row.source)} - - ) : ( - - )} - {sla.label} diff --git a/src/hooks/use-worklist.ts b/src/hooks/use-worklist.ts index 3b3b1b8..3a46536 100644 --- a/src/hooks/use-worklist.ts +++ b/src/hooks/use-worklist.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { apiClient } from '@/lib/api-client'; +import { notify } from '@/lib/toast'; type MissedCall = { id: string; @@ -133,9 +134,30 @@ export const useWorklist = (): UseWorklistResult => { useEffect(() => { fetchWorklist(); - // Refresh every 30 seconds - const interval = setInterval(fetchWorklist, 30000); - return () => clearInterval(interval); + // SSE stream for instant worklist updates. No polling fallback — + // if SSE breaks, the worklist stops updating and we fix the SSE, + // not paper over it with a poll. + const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; + let es: EventSource | null = null; + try { + es = new EventSource(`${API_URL}/api/supervisor/worklist/stream`); + es.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + console.log('[WORKLIST-SSE]', data); + fetchWorklist(); + if (data.type === 'missed-call') { + const name = data.callerName ?? data.callerPhone ?? 'Unknown'; + notify.warning('Missed Call', `${name} — needs callback`); + } + } catch {} + }; + es.onerror = () => { + console.warn('[WORKLIST-SSE] Connection error — EventSource will auto-reconnect'); + }; + } catch {} + + return () => { es?.close(); }; }, [fetchWorklist]); return { ...data, loading, error, refresh: fetchWorklist }; diff --git a/src/pages/call-history.tsx b/src/pages/call-history.tsx index f9b646b..6aea103 100644 --- a/src/pages/call-history.tsx +++ b/src/pages/call-history.tsx @@ -16,11 +16,11 @@ import { Badge } from '@/components/base/badges/badges'; import { Button } from '@/components/base/buttons/button'; import { Input } from '@/components/base/input/input'; import { Select } from '@/components/base/select/select'; -import { ClickToCallButton } from '@/components/call-desk/click-to-call-button'; +import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; import { formatShortDate, formatPhone } from '@/lib/format'; -import { computeSlaStatus } from '@/lib/scoring'; -import { cx } from '@/utils/cx'; +// cx removed — no longer used after SLA column removal import { useData } from '@/providers/data-provider'; +import { useAuth } from '@/providers/auth-provider'; import { PaginationCardDefault } from '@/components/application/pagination/pagination'; import type { Call, CallDirection, CallDisposition } from '@/types/entities'; @@ -54,13 +54,6 @@ const formatDuration = (seconds: number | null): string => { return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; }; -const formatPhoneDisplay = (call: Call): string => { - if (call.callerNumber && call.callerNumber.length > 0) { - return formatPhone(call.callerNumber[0]); - } - return '\u2014'; -}; - const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callStatus'] }> = ({ direction, status }) => { if (status === 'MISSED') { return ; @@ -71,12 +64,6 @@ const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callSta return ; }; -const getCallSla = (call: Call): { percent: number; status: 'low' | 'medium' | 'high' | 'critical' } | null => { - if (call.sla == null) return null; - const percent = Math.round(call.sla); - return { percent, status: computeSlaStatus(percent) }; -}; - const RecordingPlayer: FC<{ url: string }> = ({ url }) => { const audioRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); @@ -88,8 +75,7 @@ const RecordingPlayer: FC<{ url: string }> = ({ url }) => { audio.pause(); setIsPlaying(false); } else { - audio.play().catch(() => setIsPlaying(false)); - setIsPlaying(true); + audio.play().then(() => setIsPlaying(true)).catch(() => {}); } }; @@ -119,11 +105,11 @@ const PAGE_SIZE = 20; export const CallHistoryPage = () => { const { calls, leads } = useData(); + const { user, isAdmin } = useAuth(); const [search, setSearch] = useState(''); const [filter, setFilter] = useState('all'); const [page, setPage] = useState(1); - // Build a map of lead names by ID for enrichment const leadNameMap = useMemo(() => { const map = new Map(); for (const lead of leads) { @@ -135,7 +121,10 @@ export const CallHistoryPage = () => { return map; }, [leads]); - // Sort by time (newest first) and apply filters + // Agent sees only their own calls; supervisor sees all + const agentConfig = localStorage.getItem('helix_agent_config'); + const myAgentId = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null; + const filteredCalls = useMemo(() => { let result = [...calls].sort((a, b) => { const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0; @@ -143,37 +132,53 @@ export const CallHistoryPage = () => { return dateB - dateA; }); - // Direction / status filter. "Inbound" shows answered inbound only — missed - // calls have their own dedicated filter so they don't double-appear. + // CC agent: filter to own calls only. + // Match on the authoritative agent relation (set by CDR enrichment) + // or the raw agentName for unenriched rows. Chain names like + // "RamaiahAdmin -> GlobalHealthX" are split — last segment is + // the final handler. Missed calls have no handler and are excluded + // from the agent's personal history (they belong on the Missed + // Calls queue). + if (!isAdmin && myAgentId) { + const myId = myAgentId.toLowerCase(); + result = result.filter((c) => { + // Missed calls have no handler — exclude from agent history + if (c.callStatus === 'MISSED') return false; + // Authoritative: agent relation from CDR enrichment + if (c.agent?.ozonetelAgentId?.toLowerCase() === myId) return true; + // Fallback: parse chain in agentName, match last segment + if (c.agentName) { + const segments = c.agentName.split('->').map(s => s.trim().toLowerCase()); + const finalHandler = segments[segments.length - 1]; + if (finalHandler === myId) return true; + } + return false; + }); + } + if (filter === 'inbound') result = result.filter((c) => c.callDirection === 'INBOUND' && c.callStatus !== 'MISSED'); else if (filter === 'outbound') result = result.filter((c) => c.callDirection === 'OUTBOUND'); else if (filter === 'missed') result = result.filter((c) => c.callStatus === 'MISSED'); - // Search filter if (search.trim()) { const q = search.toLowerCase(); result = result.filter((c) => { const name = c.leadName ?? leadNameMap.get(c.leadId ?? '') ?? ''; const phone = c.callerNumber?.[0]?.number ?? ''; const agent = c.agentName ?? ''; - return ( - name.toLowerCase().includes(q) || - phone.includes(q) || - agent.toLowerCase().includes(q) - ); + return name.toLowerCase().includes(q) || phone.includes(q) || agent.toLowerCase().includes(q); }); } return result; - }, [calls, filter, search, leadNameMap]); + }, [calls, filter, search, leadNameMap, isAdmin, myAgentId, user.id]); - const completedCount = filteredCalls.filter((c) => c.callStatus !== 'MISSED').length; + const completedCount = filteredCalls.filter((c) => c.callStatus === 'COMPLETED').length; const missedCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length; const totalPages = Math.max(1, Math.ceil(filteredCalls.length / PAGE_SIZE)); const pagedCalls = filteredCalls.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); - // Reset page when filter/search changes useEffect(() => { setPage(1); }, [filter, search]); // eslint-disable-line react-hooks/exhaustive-deps return ( @@ -181,7 +186,7 @@ export const CallHistoryPage = () => {
{ - - - + {/* Agent columns — only visible for supervisor */} + {isAdmin && } + {isAdmin && } - {(call) => { - const phoneRawForName = call.callerNumber?.[0]?.number ?? ''; - const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? (phoneRawForName ? formatPhone({ number: phoneRawForName, callingCode: '+91' }) : 'Unknown'); - const phoneDisplay = formatPhoneDisplay(call); const phoneRaw = call.callerNumber?.[0]?.number ?? ''; + const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? (phoneRaw ? formatPhone({ number: phoneRaw, callingCode: '+91' }) : 'Unknown'); const dispositionCfg = call.disposition !== null ? dispositionConfig[call.disposition] : null; return ( @@ -256,9 +258,14 @@ export const CallHistoryPage = () => { - - {phoneDisplay} - + {phoneRaw ? ( + + ) : ( + {'\u2014'} + )} @@ -274,53 +281,27 @@ export const CallHistoryPage = () => { {'\u2014'} )} - - {(() => { - const sla = getCallSla(call); - if (!sla) return ; - return ( - - - {sla.percent}% - - ); - })()} - - - - {call.agentName ?? '\u2014'} - - - - {call.recordingUrl ? ( - - ) : ( - {'\u2014'} - )} - + {isAdmin && ( + + + {call.agent?.name ?? call.agentName ?? '\u2014'} + + + )} + {isAdmin && ( + + {call.recordingUrl ? ( + + ) : ( + {'\u2014'} + )} + + )} {call.startedAt ? formatShortDate(call.startedAt) : '\u2014'} - - {phoneRaw ? ( - - ) : ( - {'\u2014'} - )} - ); }}