From b3ba840dec3814ca03182c2d3435f48c22d19b04 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Fri, 17 Apr 2026 11:16:00 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20coaching=20panel=20=E2=80=94=20sum?= =?UTF-8?q?mary=20card,=20suggestions,=20structured=20responses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ai-summary-card.tsx: Zone 1 — patient profile (name, badges, AI summary, source/campaign, appointment pills) - ai-suggestions.tsx: Zone 2 — collapsible suggestion pills with expand, script display, "Tell me more" action - ai-chat-panel.tsx: rewritten — orchestrates 3 zones, parses structured JSON from AI responses, progressive suggestion updates - context-panel.tsx: removed P360 tab toggle and all legacy sections, single coaching surface with callerSummary prop Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/call-desk/ai-chat-panel.tsx | 114 ++++--- src/components/call-desk/ai-suggestions.tsx | 102 ++++++ src/components/call-desk/ai-summary-card.tsx | 88 ++++++ src/components/call-desk/context-panel.tsx | 313 ++----------------- 4 files changed, 283 insertions(+), 334 deletions(-) create mode 100644 src/components/call-desk/ai-suggestions.tsx create mode 100644 src/components/call-desk/ai-summary-card.tsx diff --git a/src/components/call-desk/ai-chat-panel.tsx b/src/components/call-desk/ai-chat-panel.tsx index ce497a8..4e0b70f 100644 --- a/src/components/call-desk/ai-chat-panel.tsx +++ b/src/components/call-desk/ai-chat-panel.tsx @@ -1,9 +1,11 @@ import type { ReactNode } from 'react'; -import { useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import { useThemeTokens } from '@/providers/theme-token-provider'; import { useChat } from '@ai-sdk/react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPaperPlaneTop, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons'; +import { AiSummaryCard, type CallerSummary } from './ai-summary-card'; +import { AiSuggestions, type Suggestion } from './ai-suggestions'; const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; @@ -16,14 +18,10 @@ type CallerContext = { interface AiChatPanelProps { callerContext?: CallerContext; + callerSummary?: CallerSummary | null; onChatStart?: () => void; } -// Supervisor has different quick-action prompts than the CC agent — they -// ask about team metrics, not patient / doctor info. Hardcoded here rather -// than in theme tokens because the prompts map 1:1 to the supervisor tool -// set in ai-chat.controller.ts (get_agent_performance, get_call_summary, -// get_campaign_stats) — changing the tools means changing these prompts. const SUPERVISOR_QUICK_ACTIONS = [ { label: 'Agent performance', prompt: 'Show me agent performance this week.' }, { label: 'Call summary', prompt: 'Summarize call activity this week.' }, @@ -33,27 +31,49 @@ const SUPERVISOR_QUICK_ACTIONS = [ const SUPERVISOR_INTRO = 'Ask me about agent performance, call trends, or campaign stats.'; -export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => { +const parseAiResponse = (content: string): { message: string; suggestions: Suggestion[] } => { + const trimmed = content.trim(); + try { + const parsed = JSON.parse(trimmed); + if (parsed.message) { + return { + message: parsed.message, + suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions : [], + }; + } + } catch {} + return { message: content, suggestions: [] }; +}; + +export const AiChatPanel = ({ callerContext, callerSummary, onChatStart }: AiChatPanelProps) => { const { tokens } = useThemeTokens(); const isSupervisor = callerContext?.type === 'supervisor'; const quickActions = isSupervisor ? SUPERVISOR_QUICK_ACTIONS : tokens.ai.quickActions; const introText = isSupervisor ? SUPERVISOR_INTRO : 'Ask me about doctors, clinics, packages, or patient info.'; const messagesEndRef = useRef(null); const chatStartedRef = useRef(false); + const [suggestions, setSuggestions] = useState([]); const token = localStorage.getItem('helix_access_token') ?? ''; const { messages, input, handleSubmit, handleInputChange, isLoading, append, setMessages } = useChat({ api: `${API_URL}/api/ai/stream`, streamProtocol: 'text', - headers: { - 'Authorization': `Bearer ${token}`, - }, - body: { - context: callerContext, - }, + headers: { 'Authorization': `Bearer ${token}` }, + body: { context: callerContext }, }); + useEffect(() => { + if (isLoading) return; + const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant'); + if (lastAssistant) { + const parsed = parseAiResponse(lastAssistant.content); + if (parsed.suggestions.length > 0) { + setSuggestions(parsed.suggestions); + } + } + }, [messages, isLoading]); + useEffect(() => { const el = messagesEndRef.current; if (el?.parentElement) { @@ -65,37 +85,27 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => } }, [messages, onChatStart]); - // Auto-fire a patient-summary request when a caller with a leadId appears - // on the panel. Resets whenever the caller changes (new incoming call) or - // the call ends (leadId clears), so each call starts fresh. The sidecar's - // AI agent inspects the leadId and replies with appointment/disposition/ - // notes history when the caller is a returning patient. const autoFiredForLeadRef = useRef(null); useEffect(() => { const leadId = callerContext?.leadId ?? null; - - // Call ended or no caller — wipe the panel so the next caller's - // context doesn't bleed over and the agent isn't staring at a stale - // summary in the worklist view between calls. if (!leadId) { if (autoFiredForLeadRef.current !== null) { autoFiredForLeadRef.current = null; setMessages([]); + setSuggestions([]); chatStartedRef.current = false; } return; } - if (autoFiredForLeadRef.current === leadId) return; - - // New caller — clear any prior chat state and fire the summary prompt. autoFiredForLeadRef.current = leadId; setMessages([]); + setSuggestions([]); chatStartedRef.current = false; const name = callerContext?.leadName ?? 'this caller'; append({ role: 'user', - content: `Give me a quick summary of ${name} — prior appointments, last disposition, any outstanding notes. If net-new, say so.`, + content: `Give me a quick summary of ${name} and suggest relevant actions for this call.`, }); }, [callerContext?.leadId, callerContext?.leadName, append, setMessages]); @@ -103,15 +113,34 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => append({ role: 'user', content: prompt }); }; + const handleTellMeMore = useCallback((suggestion: Suggestion) => { + append({ + role: 'user', + content: `Tell me more about "${suggestion.title}" — give me a detailed script and any relevant details.`, + }); + }, [append]); + + const displayMessages = messages.map(msg => { + if (msg.role === 'assistant') { + const parsed = parseAiResponse(msg.content); + return { ...msg, content: parsed.message }; + } + return msg; + }); + return ( -
+
+ {!isSupervisor && } + + {!isSupervisor && suggestions.length > 0 && ( + + )} +
- {messages.length === 0 && ( + {displayMessages.length === 0 && (
-

- {introText} -

+

{introText}

{quickActions.map((action) => (
)} - {messages.map((msg) => ( -
-
+ {displayMessages.map((msg) => ( +
+
{msg.role === 'assistant' && (
@@ -165,7 +187,7 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
-
+
); }; -// Tool result cards will be added in Phase 2 when SDK versions are aligned for data stream protocol const parseLine = (text: string): ReactNode[] => { const parts: ReactNode[] = []; const boldPattern = /\*\*(.+?)\*\*/g; let lastIndex = 0; let match: RegExpExecArray | null; - while ((match = boldPattern.exec(text)) !== null) { if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index)); parts.push({match[1]}); lastIndex = boldPattern.lastIndex; } - if (lastIndex < text.length) parts.push(text.slice(lastIndex)); return parts.length > 0 ? parts : [text]; }; @@ -209,7 +228,6 @@ const parseLine = (text: string): ReactNode[] => { const MessageContent = ({ content }: { content: string }) => { if (!content) return null; const lines = content.split('\n'); - return (
{lines.map((line, i) => { diff --git a/src/components/call-desk/ai-suggestions.tsx b/src/components/call-desk/ai-suggestions.tsx new file mode 100644 index 0000000..24ca3b9 --- /dev/null +++ b/src/components/call-desk/ai-suggestions.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTag, faArrowUp, faRotate, faClipboardCheck, faChevronDown, faChevronUp } from '@fortawesome/pro-duotone-svg-icons'; +import { cx } from '@/utils/cx'; + +export type Suggestion = { + id: string; + type: 'upsell' | 'crosssell' | 'retention' | 'operational'; + title: string; + script: string; + priority: 'high' | 'medium' | 'low'; +}; + +interface AiSuggestionsProps { + suggestions: Suggestion[]; + onTellMeMore: (suggestion: Suggestion) => void; +} + +const TYPE_ICONS = { + upsell: faArrowUp, + crosssell: faTag, + retention: faRotate, + operational: faClipboardCheck, +}; + +const PRIORITY_COLORS = { + high: 'bg-error-solid', + medium: 'bg-warning-solid', + low: 'bg-success-solid', +}; + +export const AiSuggestions = ({ suggestions, onTellMeMore }: AiSuggestionsProps) => { + const [collapsed, setCollapsed] = useState(false); + const [expandedId, setExpandedId] = useState(null); + + if (suggestions.length === 0) return null; + + return ( +
+ + + {!collapsed && ( +
+ {suggestions.map((s) => { + const isExpanded = expandedId === s.id; + return ( +
+ + + {isExpanded && ( +
+

+ {s.script} +

+ +
+ )} +
+ ); + })} +
+ )} +
+ ); +}; diff --git a/src/components/call-desk/ai-summary-card.tsx b/src/components/call-desk/ai-summary-card.tsx new file mode 100644 index 0000000..133a34c --- /dev/null +++ b/src/components/call-desk/ai-summary-card.tsx @@ -0,0 +1,88 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faUser, faCalendarCheck, faPhone } from '@fortawesome/pro-duotone-svg-icons'; +import { Badge } from '@/components/base/badges/badges'; + +export type CallerSummary = { + name: string; + phone: string; + isNew: boolean; + aiSummary?: string | null; + leadSource?: string | null; + utmCampaign?: string | null; + nextAppointment?: { scheduledAt: string; doctorName: string; department: string } | null; + lastAppointment?: { scheduledAt: string; status: string; department: string } | null; +}; + +interface AiSummaryCardProps { + caller: CallerSummary | null; +} + +const formatDate = (dateStr: string): string => { + const d = new Date(dateStr); + return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }); +}; + +export const AiSummaryCard = ({ caller }: AiSummaryCardProps) => { + if (!caller) { + return ( +
+

Select a patient or receive a call

+
+ ); + } + + return ( +
+
+
+ +
+
+
+ {caller.name || caller.phone} + + {caller.isNew ? 'New' : 'Returning'} + +
+ {caller.name && ( + {caller.phone} + )} +
+
+ + {caller.aiSummary && ( +

{caller.aiSummary}

+ )} + + {(caller.leadSource || caller.utmCampaign) && ( +
+ {caller.leadSource && ( + {caller.leadSource} + )} + {caller.utmCampaign && ( + {caller.utmCampaign} + )} +
+ )} + +
+ {caller.nextAppointment && ( +
+ + + {formatDate(caller.nextAppointment.scheduledAt)} · {caller.nextAppointment.doctorName} + +
+ )} + {caller.lastAppointment && ( +
+ + + Last: {formatDate(caller.lastAppointment.scheduledAt)} · {caller.lastAppointment.status} + +
+ )} +
+
+ ); +}; diff --git a/src/components/call-desk/context-panel.tsx b/src/components/call-desk/context-panel.tsx index 9741113..cd897fa 100644 --- a/src/components/call-desk/context-panel.tsx +++ b/src/components/call-desk/context-panel.tsx @@ -1,28 +1,13 @@ import { useState, useCallback, useMemo } from 'react'; -import { useNavigate } from 'react-router'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { - faSparkles, faPhone, faChevronDown, faChevronUp, - faCalendarCheck, faClockRotateLeft, faPhoneMissed, - faPhoneArrowDown, faPhoneArrowUp, faListCheck, -} from '@fortawesome/pro-duotone-svg-icons'; import { AiChatPanel } from './ai-chat-panel'; -import { Badge } from '@/components/base/badges/badges'; -import { formatPhone, formatShortDate } from '@/lib/format'; -import { cx } from '@/utils/cx'; -import type { LeadActivity, Call, FollowUp, Patient, Appointment } from '@/types/entities'; +import type { Appointment } from '@/types/entities'; import { AppointmentForm } from './appointment-form'; -// The context panel can render for any worklist item — not just leads. -// Missed calls and follow-ups provide a subset of the fields (phone + -// patientId + name) without a full Lead entity. ContextPanelSubject -// captures the minimum the panel needs to render P360. export type ContextPanelSubject = { id: string; contactName?: { firstName: string; lastName: string } | null; contactPhone?: Array<{ number: string; callingCode: string }> | null; patientId?: string | null; - // Lead-specific fields — present when the subject IS a lead leadSource?: string | null; leadStatus?: string | null; aiSummary?: string | null; @@ -33,55 +18,17 @@ export type ContextPanelSubject = { interface ContextPanelProps { selectedLead: ContextPanelSubject | null; - activities: LeadActivity[]; - calls: Call[]; - followUps: FollowUp[]; + activities: any[]; + calls: any[]; + followUps: any[]; appointments: Appointment[]; - patients: Patient[]; + patients: any[]; callerPhone?: string; isInCall?: boolean; callUcid?: string | null; } -const formatTimeAgo = (dateStr: string): string => { - const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000); - if (minutes < 1) return 'Just now'; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; -}; - -const formatDuration = (sec: number): string => { - if (sec < 60) return `${sec}s`; - return `${Math.floor(sec / 60)}m ${sec % 60}s`; -}; - -const SectionHeader = ({ icon, label, count, expanded, onToggle }: { - icon: any; label: string; count?: number; expanded: boolean; onToggle: () => void; -}) => ( - -); - -export const ContextPanel = ({ selectedLead, activities, calls, followUps, appointments, patients, callerPhone, isInCall }: ContextPanelProps) => { - const navigate = useNavigate(); - const [contextExpanded, setContextExpanded] = useState(true); - const [insightExpanded, setInsightExpanded] = useState(true); - const [actionsExpanded, setActionsExpanded] = useState(true); - const [recentExpanded, setRecentExpanded] = useState(true); +export const ContextPanel = ({ selectedLead, appointments, callerPhone }: ContextPanelProps) => { const [editingAppointment, setEditingAppointment] = useState(null); const lead = selectedLead; @@ -96,21 +43,6 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi leadName: fullName, } : callerPhone ? { callerPhone } : undefined; - // Filter data for this lead - const leadCalls = useMemo(() => - calls.filter(c => c.leadId === lead?.id || (callerPhone && c.callerNumber?.[0]?.number?.endsWith(callerPhone))) - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - .slice(0, 5), - [calls, lead, callerPhone], - ); - - const leadFollowUps = useMemo(() => - followUps.filter(f => f.patientId === lead?.patientId && f.followUpStatus !== 'COMPLETED' && f.followUpStatus !== 'CANCELLED') - .sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime()) - .slice(0, 3), - [followUps, lead], - ); - const leadAppointments = useMemo(() => { const patientId = lead?.patientId; if (!patientId) return []; @@ -120,29 +52,9 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi .slice(0, 3); }, [appointments, lead]); - const leadActivities = useMemo(() => - activities.filter(a => a.leadId === lead?.id) - .sort((a, b) => new Date(b.occurredAt ?? '').getTime() - new Date(a.occurredAt ?? '').getTime()) - .slice(0, 5), - [activities, lead], - ); + const handleChatStart = useCallback(() => {}, []); - // Linked patient - const linkedPatient = useMemo(() => - patients.find(p => p.id === lead?.patientId), - [patients, lead], - ); - - // Auto-collapse context sections when chat starts - const handleChatStart = useCallback(() => { - setContextExpanded(false); - }, []); - - const hasContext = !!(lead?.aiSummary || leadCalls.length || leadFollowUps.length || leadAppointments.length || leadActivities.length); - - // Edit mode takes over the whole right panel — otherwise the - // AppointmentForm competes with the AI panel + context blocks for - // vertical space and gets crushed into a tiny strip at the bottom. + // Edit mode takes over the whole right panel if (editingAppointment) { return (
@@ -178,199 +90,28 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi ); } + // Build callerSummary for the AI coaching panel + const nextAppt = leadAppointments.find(a => a.appointmentStatus === 'SCHEDULED' && new Date(a.scheduledAt ?? '') > new Date()); + const lastAppt = leadAppointments.find(a => a.appointmentStatus === 'COMPLETED'); + const callerSummary = lead ? { + name: fullName, + phone: phone?.number ?? callerPhone ?? '', + isNew: false, + aiSummary: (lead as any).aiSummary ?? null, + leadSource: (lead as any).leadSource ?? null, + utmCampaign: (lead as any).utmCampaign ?? null, + nextAppointment: nextAppt ? { scheduledAt: nextAppt.scheduledAt ?? '', doctorName: nextAppt.doctorName ?? '', department: nextAppt.department ?? '' } : null, + lastAppointment: lastAppt ? { scheduledAt: lastAppt.scheduledAt ?? '', status: lastAppt.appointmentStatus ?? '', department: lastAppt.department ?? '' } : null, + } : callerPhone ? { + name: '', + phone: callerPhone, + isNew: true, + } : null; + return (
- {/* Lead header — always visible */} - {lead && ( -
- - - {/* Expanded context sections */} - {contextExpanded && ( -
- {/* AI Insight */} - {lead.aiSummary && ( -
- setInsightExpanded(!insightExpanded)} /> - {insightExpanded && ( -
-

{lead.aiSummary}

- {lead.aiSuggestedAction && ( -

{lead.aiSuggestedAction}

- )} -
- )} -
- )} - - {/* Campaign info */} - {(lead.utmCampaign || lead.campaignId) && ( -
- Campaign - - {lead.utmCampaign ?? lead.campaignId} - -
- )} - - {/* Quick Actions — upcoming appointments + follow-ups + linked patient */} - {(leadAppointments.length > 0 || leadFollowUps.length > 0 || linkedPatient) && ( -
- setActionsExpanded(!actionsExpanded)} /> - {actionsExpanded && ( -
- {leadAppointments.map(appt => ( -
- -
- - {appt.doctorName ?? 'Appointment'} - - - {appt.department} - - {appt.scheduledAt && ( - - — {formatShortDate(appt.scheduledAt)} - - )} -
- - {appt.appointmentStatus?.replace(/_/g, ' ') ?? 'Scheduled'} - - -
- ))} - {leadFollowUps.map(fu => ( -
- -
- - {fu.followUpType?.replace(/_/g, ' ') ?? 'Follow-up'} - - {fu.scheduledAt && ( - - {formatShortDate(fu.scheduledAt)} - - )} -
- - {fu.followUpStatus?.replace(/_/g, ' ') ?? 'Pending'} - -
- ))} - {linkedPatient && ( -
- - - Patient: {linkedPatient.fullName?.firstName} {linkedPatient.fullName?.lastName} - - {linkedPatient.patientType && ( - {linkedPatient.patientType} - )} - -
- )} -
- )} -
- )} - - {/* Recent calls + activities */} - {(leadCalls.length > 0 || leadActivities.length > 0) && ( -
- setRecentExpanded(!recentExpanded)} - /> - {recentExpanded && ( -
- {leadCalls.map(call => ( -
- -
- - {call.callStatus === 'MISSED' ? 'Missed' : call.callDirection === 'INBOUND' ? 'Inbound' : 'Outbound'} call - - {call.durationSeconds != null && call.durationSeconds > 0 && ( - — {formatDuration(call.durationSeconds)} - )} - {call.disposition && ( - , {call.disposition.replace(/_/g, ' ')} - )} -
- - {formatTimeAgo(call.startedAt ?? call.createdAt)} - -
- ))} - {leadActivities - .filter(a => !leadCalls.some(c => a.summary?.includes(c.callerNumber?.[0]?.number ?? '---'))) - .slice(0, 3) - .map(a => ( -
- - {a.summary} - {a.occurredAt && ( - {formatTimeAgo(a.occurredAt)} - )} -
- )) - } -
- )} -
- )} - - {/* No context available */} - {!hasContext && ( -

No history for this lead yet.

- )} -
- )} -
- )} - - {/* AI Chat — fills remaining space */}
- +
);