Files
helix-engage/src/components/call-desk/recording-analysis.tsx
saridsa2 8470dd03c7 fix: UI polish — nav labels, date picker, rules engine, error messages
- Sidebar: removed "Master" from nav labels (Leads, Patients, Appointments, Call Log)
- Appointment form: Dept + Doctor in 2-col row, Date below, disabled cascade
- DatePicker: placement="bottom start" + shouldFlip fixes popover positioning
- Team Performance: default to "Week", grid KPI cards, chart legend spacing
- Rules Engine: manual save (removed auto-debounce), Reset to Defaults uses
  DEFAULT_PRIORITY_CONFIG (no template endpoint), removed dead saveTimerRef
- Automation rules: 6 showcase cards with trigger/condition/action, replaced
  agent-specific rule with generic round-robin
- Recording analysis: friendly error message with retry instead of raw Deepgram error
- Sidebar active/hover: brand color reference for theming

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:55:16 +05:30

303 lines
13 KiB
TypeScript

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<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
const toggle = () => {
if (!audioRef.current) return;
if (playing) { audioRef.current.pause(); } else { audioRef.current.play(); }
setPlaying(!playing);
};
return (
<div className="flex items-center gap-2">
<button
onClick={toggle}
className="flex size-8 items-center justify-center rounded-full bg-brand-solid text-white hover:opacity-90 transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={playing ? faPause : faPlay} className="size-3.5" />
</button>
<span className="text-xs text-tertiary">{playing ? 'Playing...' : 'Play recording'}</span>
<audio ref={audioRef} src={url} onEnded={() => setPlaying(false)} />
</div>
);
};
// Insights section rendered after analysis completes
const InsightsSection = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div className="rounded-lg bg-secondary p-3">
<span className="text-xs font-semibold text-tertiary uppercase tracking-wider">{label}</span>
<div className="mt-1">{children}</div>
</div>
);
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<Analysis | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<Analysis>('/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 (
<SlideoutMenu isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
{({ close }) => (
<>
<SlideoutMenu.Header onClose={close}>
<div className="flex flex-col gap-1.5 pr-8">
<h2 className="text-lg font-semibold text-primary">Call Analysis</h2>
<div className="flex flex-wrap items-center gap-2 text-sm text-tertiary">
<Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>
{agentName && <span>{agentName}</span>}
{formattedPhone && (
<>
<span className="text-quaternary">-</span>
<span>{formattedPhone}</span>
</>
)}
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-quaternary">
{startedAt && <span>{formatDateOnly(startedAt)}</span>}
{durationSec != null && durationSec > 0 && <span>{formatDuration(durationSec)}</span>}
{disposition && (
<Badge size="sm" color="gray" type="pill-color">
{disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}
</Badge>
)}
</div>
<div className="mt-1">
<SlideoutPlayer url={recordingUrl} />
</div>
</div>
</SlideoutMenu.Header>
<SlideoutMenu.Content>
{loading && (
<div className="flex flex-col items-center justify-center gap-3 py-16">
<FontAwesomeIcon icon={faSpinner} className="size-6 animate-spin text-brand-secondary" />
<p className="text-sm text-tertiary">Analyzing recording...</p>
<p className="text-xs text-quaternary">Transcribing and generating insights</p>
</div>
)}
{error && !loading && (
<div className="flex flex-col items-center gap-3 py-12">
<p className="text-sm text-tertiary">Transcription is temporarily unavailable. Please try again.</p>
<Button
size="sm"
color="secondary"
iconLeading={<FontAwesomeIcon icon={faWaveformLines} data-icon className="size-3.5" />}
onClick={() => {
hasTriggered.current = false;
setLoading(true);
setError(null);
apiClient.post<Analysis>('/api/recordings/analyze', { recordingUrl, callId })
.then((result) => setAnalysis(result))
.catch((err: any) => setError(err.message ?? 'Analysis failed'))
.finally(() => setLoading(false));
}}
>
Retry
</Button>
</div>
)}
{analysis && !loading && (
<AnalysisResults analysis={analysis} />
)}
</SlideoutMenu.Content>
</>
)}
</SlideoutMenu>
);
};
// Separated analysis results display for readability
const AnalysisResults = ({ analysis }: { analysis: Analysis }) => {
const sentCfg = sentimentConfig[analysis.sentiment];
return (
<div className="flex flex-col gap-4">
{/* Sentiment + topics */}
<div className="flex flex-wrap items-center gap-2">
<Badge size="sm" color={sentCfg.color} type="pill-color">{sentCfg.label}</Badge>
{analysis.insights.keyTopics.slice(0, 4).map((topic) => (
<Badge key={topic} size="sm" color="gray" type="pill-color">{topic}</Badge>
))}
</div>
{/* Summary */}
{analysis.summary && (
<div className="rounded-lg bg-secondary p-3">
<span className="text-xs font-semibold text-tertiary uppercase tracking-wider">Summary</span>
<p className="mt-1 text-sm text-primary">{analysis.summary}</p>
</div>
)}
{/* Call outcome */}
<div className="rounded-lg bg-brand-secondary p-3">
<span className="text-xs font-semibold text-brand-tertiary uppercase tracking-wider">Call Outcome</span>
<p className="mt-1 text-sm text-primary_on-brand font-medium">{analysis.insights.callOutcome}</p>
</div>
{/* Insights grid */}
<div className="grid grid-cols-1 gap-3">
<InsightsSection label="Patient Satisfaction">
<p className="text-sm text-primary">{analysis.insights.patientSatisfaction}</p>
</InsightsSection>
{analysis.insights.actionItems.length > 0 && (
<InsightsSection label="Action Items">
<ul className="space-y-0.5">
{analysis.insights.actionItems.map((item, i) => (
<li key={i} className="text-sm text-primary">- {item}</li>
))}
</ul>
</InsightsSection>
)}
{analysis.insights.coachingNotes.length > 0 && (
<InsightsSection label="Coaching Notes">
<ul className="space-y-0.5">
{analysis.insights.coachingNotes.map((note, i) => (
<li key={i} className="text-sm text-primary">- {note}</li>
))}
</ul>
</InsightsSection>
)}
{analysis.insights.complianceFlags.length > 0 && (
<div className="rounded-lg bg-error-secondary p-3">
<span className="text-xs font-semibold text-error-primary uppercase tracking-wider">Compliance Flags</span>
<ul className="mt-1 space-y-0.5">
{analysis.insights.complianceFlags.map((flag, i) => (
<li key={i} className="text-sm text-error-primary">- {flag}</li>
))}
</ul>
</div>
)}
</div>
{/* Transcript */}
{analysis.transcript.length > 0 && (
<div>
<span className="text-xs font-semibold text-tertiary uppercase tracking-wider">Transcript</span>
<div className="mt-2 space-y-2 rounded-lg bg-secondary p-3">
{analysis.transcript.map((u, i) => {
const isAgent = u.speaker === 0;
return (
<div key={i} className="flex gap-2">
<span className="shrink-0 text-xs text-quaternary tabular-nums w-10">{formatTimestamp(u.start)}</span>
<span className={cx(
'text-xs font-semibold shrink-0 w-16',
isAgent ? 'text-brand-secondary' : 'text-success-primary',
)}>
{isAgent ? 'Agent' : 'Customer'}
</span>
<span className="text-sm text-primary">{u.text}</span>
</div>
);
})}
</div>
</div>
)}
</div>
);
};