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} + /> + ); + })()} );