mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
feat: AI coaching panel — summary card, suggestions, structured responses
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
import type { ReactNode } from 'react';
|
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 { useThemeTokens } from '@/providers/theme-token-provider';
|
||||||
import { useChat } from '@ai-sdk/react';
|
import { useChat } from '@ai-sdk/react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faPaperPlaneTop, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
|
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';
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||||
|
|
||||||
@@ -16,14 +18,10 @@ type CallerContext = {
|
|||||||
|
|
||||||
interface AiChatPanelProps {
|
interface AiChatPanelProps {
|
||||||
callerContext?: CallerContext;
|
callerContext?: CallerContext;
|
||||||
|
callerSummary?: CallerSummary | null;
|
||||||
onChatStart?: () => void;
|
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 = [
|
const SUPERVISOR_QUICK_ACTIONS = [
|
||||||
{ label: 'Agent performance', prompt: 'Show me agent performance this week.' },
|
{ label: 'Agent performance', prompt: 'Show me agent performance this week.' },
|
||||||
{ label: 'Call summary', prompt: 'Summarize call activity 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.';
|
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 { tokens } = useThemeTokens();
|
||||||
const isSupervisor = callerContext?.type === 'supervisor';
|
const isSupervisor = callerContext?.type === 'supervisor';
|
||||||
const quickActions = isSupervisor ? SUPERVISOR_QUICK_ACTIONS : tokens.ai.quickActions;
|
const quickActions = isSupervisor ? SUPERVISOR_QUICK_ACTIONS : tokens.ai.quickActions;
|
||||||
const introText = isSupervisor ? SUPERVISOR_INTRO : 'Ask me about doctors, clinics, packages, or patient info.';
|
const introText = isSupervisor ? SUPERVISOR_INTRO : 'Ask me about doctors, clinics, packages, or patient info.';
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const chatStartedRef = useRef(false);
|
const chatStartedRef = useRef(false);
|
||||||
|
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||||
|
|
||||||
const token = localStorage.getItem('helix_access_token') ?? '';
|
const token = localStorage.getItem('helix_access_token') ?? '';
|
||||||
|
|
||||||
const { messages, input, handleSubmit, handleInputChange, isLoading, append, setMessages } = useChat({
|
const { messages, input, handleSubmit, handleInputChange, isLoading, append, setMessages } = useChat({
|
||||||
api: `${API_URL}/api/ai/stream`,
|
api: `${API_URL}/api/ai/stream`,
|
||||||
streamProtocol: 'text',
|
streamProtocol: 'text',
|
||||||
headers: {
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
'Authorization': `Bearer ${token}`,
|
body: { context: callerContext },
|
||||||
},
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
const el = messagesEndRef.current;
|
const el = messagesEndRef.current;
|
||||||
if (el?.parentElement) {
|
if (el?.parentElement) {
|
||||||
@@ -65,37 +85,27 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
|||||||
}
|
}
|
||||||
}, [messages, onChatStart]);
|
}, [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<string | null>(null);
|
const autoFiredForLeadRef = useRef<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const leadId = callerContext?.leadId ?? null;
|
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 (!leadId) {
|
||||||
if (autoFiredForLeadRef.current !== null) {
|
if (autoFiredForLeadRef.current !== null) {
|
||||||
autoFiredForLeadRef.current = null;
|
autoFiredForLeadRef.current = null;
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setSuggestions([]);
|
||||||
chatStartedRef.current = false;
|
chatStartedRef.current = false;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (autoFiredForLeadRef.current === leadId) return;
|
if (autoFiredForLeadRef.current === leadId) return;
|
||||||
|
|
||||||
// New caller — clear any prior chat state and fire the summary prompt.
|
|
||||||
autoFiredForLeadRef.current = leadId;
|
autoFiredForLeadRef.current = leadId;
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setSuggestions([]);
|
||||||
chatStartedRef.current = false;
|
chatStartedRef.current = false;
|
||||||
const name = callerContext?.leadName ?? 'this caller';
|
const name = callerContext?.leadName ?? 'this caller';
|
||||||
append({
|
append({
|
||||||
role: 'user',
|
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]);
|
}, [callerContext?.leadId, callerContext?.leadName, append, setMessages]);
|
||||||
|
|
||||||
@@ -103,15 +113,34 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
|||||||
append({ role: 'user', content: prompt });
|
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 (
|
return (
|
||||||
<div className="flex h-full flex-col p-3">
|
<div className="flex h-full flex-col gap-2 p-3">
|
||||||
|
{!isSupervisor && <AiSummaryCard caller={callerSummary ?? null} />}
|
||||||
|
|
||||||
|
{!isSupervisor && suggestions.length > 0 && (
|
||||||
|
<AiSuggestions suggestions={suggestions} onTellMeMore={handleTellMeMore} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1 space-y-3 overflow-y-auto min-h-0">
|
<div className="flex-1 space-y-3 overflow-y-auto min-h-0">
|
||||||
{messages.length === 0 && (
|
{displayMessages.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||||
<FontAwesomeIcon icon={faSparkles} className="mb-2 size-6 text-fg-brand-primary" />
|
<FontAwesomeIcon icon={faSparkles} className="mb-2 size-6 text-fg-brand-primary" />
|
||||||
<p className="text-xs text-tertiary">
|
<p className="text-xs text-tertiary">{introText}</p>
|
||||||
{introText}
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 flex flex-wrap justify-center gap-1.5">
|
<div className="mt-3 flex flex-wrap justify-center gap-1.5">
|
||||||
{quickActions.map((action) => (
|
{quickActions.map((action) => (
|
||||||
<button
|
<button
|
||||||
@@ -127,18 +156,11 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{messages.map((msg) => (
|
{displayMessages.map((msg) => (
|
||||||
<div
|
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||||
key={msg.id}
|
<div className={`max-w-[90%] rounded-xl px-3 py-2 text-xs leading-relaxed ${
|
||||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
msg.role === 'user' ? 'bg-brand-solid text-white' : 'bg-secondary text-primary'
|
||||||
>
|
}`}>
|
||||||
<div
|
|
||||||
className={`max-w-[90%] rounded-xl px-3 py-2 text-xs leading-relaxed ${
|
|
||||||
msg.role === 'user'
|
|
||||||
? 'bg-brand-solid text-white'
|
|
||||||
: 'bg-secondary text-primary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{msg.role === 'assistant' && (
|
{msg.role === 'assistant' && (
|
||||||
<div className="mb-1 flex items-center gap-1">
|
<div className="mb-1 flex items-center gap-1">
|
||||||
<FontAwesomeIcon icon={faSparkles} className="size-2.5 text-fg-brand-primary" />
|
<FontAwesomeIcon icon={faSparkles} className="size-2.5 text-fg-brand-primary" />
|
||||||
@@ -165,7 +187,7 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
|||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="mt-2 flex items-center gap-2 shrink-0">
|
<form onSubmit={handleSubmit} className="flex items-center gap-2 shrink-0">
|
||||||
<div className="flex flex-1 items-center rounded-lg border border-secondary bg-primary shadow-xs transition duration-100 ease-linear focus-within:border-brand focus-within:ring-4 focus-within:ring-brand-100">
|
<div className="flex flex-1 items-center rounded-lg border border-secondary bg-primary shadow-xs transition duration-100 ease-linear focus-within:border-brand focus-within:ring-4 focus-within:ring-brand-100">
|
||||||
<FontAwesomeIcon icon={faUserHeadset} className="ml-2.5 size-3.5 text-fg-quaternary" />
|
<FontAwesomeIcon icon={faUserHeadset} className="ml-2.5 size-3.5 text-fg-quaternary" />
|
||||||
<input
|
<input
|
||||||
@@ -188,20 +210,17 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
// Tool result cards will be added in Phase 2 when SDK versions are aligned for data stream protocol
|
|
||||||
|
|
||||||
const parseLine = (text: string): ReactNode[] => {
|
const parseLine = (text: string): ReactNode[] => {
|
||||||
const parts: ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
const boldPattern = /\*\*(.+?)\*\*/g;
|
const boldPattern = /\*\*(.+?)\*\*/g;
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
while ((match = boldPattern.exec(text)) !== null) {
|
while ((match = boldPattern.exec(text)) !== null) {
|
||||||
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
|
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
|
||||||
parts.push(<strong key={match.index} className="font-semibold">{match[1]}</strong>);
|
parts.push(<strong key={match.index} className="font-semibold">{match[1]}</strong>);
|
||||||
lastIndex = boldPattern.lastIndex;
|
lastIndex = boldPattern.lastIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
|
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
|
||||||
return parts.length > 0 ? parts : [text];
|
return parts.length > 0 ? parts : [text];
|
||||||
};
|
};
|
||||||
@@ -209,7 +228,6 @@ const parseLine = (text: string): ReactNode[] => {
|
|||||||
const MessageContent = ({ content }: { content: string }) => {
|
const MessageContent = ({ content }: { content: string }) => {
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{lines.map((line, i) => {
|
{lines.map((line, i) => {
|
||||||
|
|||||||
102
src/components/call-desk/ai-suggestions.tsx
Normal file
102
src/components/call-desk/ai-suggestions.tsx
Normal file
@@ -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<string | null>(null);
|
||||||
|
|
||||||
|
if (suggestions.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-secondary bg-primary">
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
className="flex w-full items-center justify-between px-3 py-2 text-left"
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-tertiary">
|
||||||
|
Suggestions ({suggestions.length})
|
||||||
|
</span>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={collapsed ? faChevronDown : faChevronUp}
|
||||||
|
className="size-2.5 text-fg-quaternary"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="space-y-1 px-2 pb-2">
|
||||||
|
{suggestions.map((s) => {
|
||||||
|
const isExpanded = expandedId === s.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className={cx(
|
||||||
|
'rounded-lg border transition duration-100 ease-linear',
|
||||||
|
isExpanded ? 'border-brand bg-brand-primary' : 'border-secondary hover:border-tertiary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedId(isExpanded ? null : s.id)}
|
||||||
|
className="flex w-full items-center gap-2 px-2.5 py-2 text-left"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={TYPE_ICONS[s.type]}
|
||||||
|
className="size-3 text-fg-brand-secondary shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="flex-1 text-xs font-medium text-primary truncate">
|
||||||
|
{s.title}
|
||||||
|
</span>
|
||||||
|
<span className={cx('size-1.5 rounded-full shrink-0', PRIORITY_COLORS[s.priority])} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-2.5 pb-2.5">
|
||||||
|
<p className="text-xs text-secondary leading-relaxed mb-2">
|
||||||
|
{s.script}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTellMeMore(s);
|
||||||
|
}}
|
||||||
|
className="text-[10px] font-medium text-brand-secondary hover:text-brand-primary transition"
|
||||||
|
>
|
||||||
|
Tell me more →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
88
src/components/call-desk/ai-summary-card.tsx
Normal file
88
src/components/call-desk/ai-summary-card.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="rounded-xl border border-secondary bg-secondary_alt p-3">
|
||||||
|
<p className="text-xs text-quaternary text-center">Select a patient or receive a call</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-secondary bg-secondary_alt p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-brand-primary">
|
||||||
|
<FontAwesomeIcon icon={faUser} className="size-3 text-fg-brand-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm font-semibold text-primary truncate">{caller.name || caller.phone}</span>
|
||||||
|
<Badge size="sm" color={caller.isNew ? 'brand' : 'success'} type="pill-color">
|
||||||
|
{caller.isNew ? 'New' : 'Returning'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{caller.name && (
|
||||||
|
<span className="text-[10px] text-tertiary">{caller.phone}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{caller.aiSummary && (
|
||||||
|
<p className="text-xs text-secondary leading-relaxed line-clamp-2">{caller.aiSummary}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(caller.leadSource || caller.utmCampaign) && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{caller.leadSource && (
|
||||||
|
<Badge size="sm" color="gray" type="pill-color">{caller.leadSource}</Badge>
|
||||||
|
)}
|
||||||
|
{caller.utmCampaign && (
|
||||||
|
<Badge size="sm" color="purple" type="pill-color">{caller.utmCampaign}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{caller.nextAppointment && (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-lg bg-success-primary px-2 py-1">
|
||||||
|
<FontAwesomeIcon icon={faCalendarCheck} className="size-2.5 text-fg-success-primary" />
|
||||||
|
<span className="text-[10px] font-medium text-success-primary">
|
||||||
|
{formatDate(caller.nextAppointment.scheduledAt)} · {caller.nextAppointment.doctorName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{caller.lastAppointment && (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-lg bg-secondary px-2 py-1">
|
||||||
|
<FontAwesomeIcon icon={faPhone} className="size-2.5 text-fg-quaternary" />
|
||||||
|
<span className="text-[10px] text-tertiary">
|
||||||
|
Last: {formatDate(caller.lastAppointment.scheduledAt)} · {caller.lastAppointment.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,28 +1,13 @@
|
|||||||
import { useState, useCallback, useMemo } from 'react';
|
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 { AiChatPanel } from './ai-chat-panel';
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import type { Appointment } from '@/types/entities';
|
||||||
import { formatPhone, formatShortDate } from '@/lib/format';
|
|
||||||
import { cx } from '@/utils/cx';
|
|
||||||
import type { LeadActivity, Call, FollowUp, Patient, Appointment } from '@/types/entities';
|
|
||||||
import { AppointmentForm } from './appointment-form';
|
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 = {
|
export type ContextPanelSubject = {
|
||||||
id: string;
|
id: string;
|
||||||
contactName?: { firstName: string; lastName: string } | null;
|
contactName?: { firstName: string; lastName: string } | null;
|
||||||
contactPhone?: Array<{ number: string; callingCode: string }> | null;
|
contactPhone?: Array<{ number: string; callingCode: string }> | null;
|
||||||
patientId?: string | null;
|
patientId?: string | null;
|
||||||
// Lead-specific fields — present when the subject IS a lead
|
|
||||||
leadSource?: string | null;
|
leadSource?: string | null;
|
||||||
leadStatus?: string | null;
|
leadStatus?: string | null;
|
||||||
aiSummary?: string | null;
|
aiSummary?: string | null;
|
||||||
@@ -33,55 +18,17 @@ export type ContextPanelSubject = {
|
|||||||
|
|
||||||
interface ContextPanelProps {
|
interface ContextPanelProps {
|
||||||
selectedLead: ContextPanelSubject | null;
|
selectedLead: ContextPanelSubject | null;
|
||||||
activities: LeadActivity[];
|
activities: any[];
|
||||||
calls: Call[];
|
calls: any[];
|
||||||
followUps: FollowUp[];
|
followUps: any[];
|
||||||
appointments: Appointment[];
|
appointments: Appointment[];
|
||||||
patients: Patient[];
|
patients: any[];
|
||||||
callerPhone?: string;
|
callerPhone?: string;
|
||||||
isInCall?: boolean;
|
isInCall?: boolean;
|
||||||
callUcid?: string | null;
|
callUcid?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTimeAgo = (dateStr: string): string => {
|
export const ContextPanel = ({ selectedLead, appointments, callerPhone }: ContextPanelProps) => {
|
||||||
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;
|
|
||||||
}) => (
|
|
||||||
<button
|
|
||||||
onClick={onToggle}
|
|
||||||
className="flex w-full items-center gap-1.5 py-1.5 text-left group"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={icon} className="size-3 text-fg-quaternary" />
|
|
||||||
<span className="text-[11px] font-bold uppercase tracking-wider text-tertiary">{label}</span>
|
|
||||||
{count !== undefined && count > 0 && (
|
|
||||||
<span className="text-[10px] font-semibold text-brand-secondary bg-brand-primary px-1.5 py-0.5 rounded-full">{count}</span>
|
|
||||||
)}
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={expanded ? faChevronUp : faChevronDown}
|
|
||||||
className="size-2.5 text-fg-quaternary ml-auto opacity-0 group-hover:opacity-100 transition duration-100 ease-linear"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
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);
|
|
||||||
const [editingAppointment, setEditingAppointment] = useState<Appointment | null>(null);
|
const [editingAppointment, setEditingAppointment] = useState<Appointment | null>(null);
|
||||||
|
|
||||||
const lead = selectedLead;
|
const lead = selectedLead;
|
||||||
@@ -96,21 +43,6 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
|||||||
leadName: fullName,
|
leadName: fullName,
|
||||||
} : callerPhone ? { callerPhone } : undefined;
|
} : 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 leadAppointments = useMemo(() => {
|
||||||
const patientId = lead?.patientId;
|
const patientId = lead?.patientId;
|
||||||
if (!patientId) return [];
|
if (!patientId) return [];
|
||||||
@@ -120,29 +52,9 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
|||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
}, [appointments, lead]);
|
}, [appointments, lead]);
|
||||||
|
|
||||||
const leadActivities = useMemo(() =>
|
const handleChatStart = useCallback(() => {}, []);
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Linked patient
|
// Edit mode takes over the whole right panel
|
||||||
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.
|
|
||||||
if (editingAppointment) {
|
if (editingAppointment) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
@@ -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 (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* Lead header — always visible */}
|
|
||||||
{lead && (
|
|
||||||
<div className="shrink-0 border-b border-secondary">
|
|
||||||
<button
|
|
||||||
onClick={() => setContextExpanded(!contextExpanded)}
|
|
||||||
className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-primary_hover transition duration-100 ease-linear"
|
|
||||||
>
|
|
||||||
{isInCall && (
|
|
||||||
<FontAwesomeIcon icon={faPhone} className="size-3 text-fg-success-primary shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-semibold text-primary truncate">{fullName || 'Unknown'}</span>
|
|
||||||
{phone && (
|
|
||||||
<span className="text-xs text-tertiary shrink-0">{formatPhone(phone)}</span>
|
|
||||||
)}
|
|
||||||
{lead.leadStatus && (
|
|
||||||
<Badge size="sm" color="brand" type="pill-color" className="shrink-0">{lead.leadStatus.replace(/_/g, ' ')}</Badge>
|
|
||||||
)}
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={contextExpanded ? faChevronUp : faChevronDown}
|
|
||||||
className="size-3 text-fg-quaternary ml-auto shrink-0"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Expanded context sections */}
|
|
||||||
{contextExpanded && (
|
|
||||||
<div className="px-4 pb-3 space-y-1 overflow-y-auto" style={{ maxHeight: '50vh' }}>
|
|
||||||
{/* AI Insight */}
|
|
||||||
{lead.aiSummary && (
|
|
||||||
<div>
|
|
||||||
<SectionHeader icon={faSparkles} label="AI Insight" expanded={insightExpanded} onToggle={() => setInsightExpanded(!insightExpanded)} />
|
|
||||||
{insightExpanded && (
|
|
||||||
<div className="rounded-lg bg-brand-primary p-2.5 mb-1">
|
|
||||||
<p className="text-xs leading-relaxed text-primary">{lead.aiSummary}</p>
|
|
||||||
{lead.aiSuggestedAction && (
|
|
||||||
<p className="mt-1.5 text-xs font-semibold text-brand-secondary">{lead.aiSuggestedAction}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Campaign info */}
|
|
||||||
{(lead.utmCampaign || lead.campaignId) && (
|
|
||||||
<div className="flex items-center gap-1.5 px-1 py-1">
|
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-tertiary">Campaign</span>
|
|
||||||
<Badge size="sm" color="brand" type="pill-color">
|
|
||||||
{lead.utmCampaign ?? lead.campaignId}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quick Actions — upcoming appointments + follow-ups + linked patient */}
|
|
||||||
{(leadAppointments.length > 0 || leadFollowUps.length > 0 || linkedPatient) && (
|
|
||||||
<div>
|
|
||||||
<SectionHeader icon={faListCheck} label="Upcoming" count={leadAppointments.length + leadFollowUps.length} expanded={actionsExpanded} onToggle={() => setActionsExpanded(!actionsExpanded)} />
|
|
||||||
{actionsExpanded && (
|
|
||||||
<div className="space-y-1 mb-1">
|
|
||||||
{leadAppointments.map(appt => (
|
|
||||||
<div key={appt.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
|
|
||||||
<FontAwesomeIcon icon={faCalendarCheck} className="size-3 text-fg-brand-primary shrink-0" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<span className="text-xs font-medium text-primary">
|
|
||||||
{appt.doctorName ?? 'Appointment'}
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] text-tertiary ml-1">
|
|
||||||
{appt.department}
|
|
||||||
</span>
|
|
||||||
{appt.scheduledAt && (
|
|
||||||
<span className="text-[11px] text-tertiary ml-1">
|
|
||||||
— {formatShortDate(appt.scheduledAt)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Badge size="sm" color={appt.appointmentStatus === 'COMPLETED' ? 'success' : 'brand'} type="pill-color">
|
|
||||||
{appt.appointmentStatus?.replace(/_/g, ' ') ?? 'Scheduled'}
|
|
||||||
</Badge>
|
|
||||||
<button
|
|
||||||
onClick={() => setEditingAppointment(appt)}
|
|
||||||
className="text-[11px] font-medium text-brand-secondary hover:text-brand-secondary_hover shrink-0"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{leadFollowUps.map(fu => (
|
|
||||||
<div key={fu.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
|
|
||||||
<FontAwesomeIcon icon={faClockRotateLeft} className="size-3 text-fg-warning-primary shrink-0" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<span className="text-xs font-medium text-primary">
|
|
||||||
{fu.followUpType?.replace(/_/g, ' ') ?? 'Follow-up'}
|
|
||||||
</span>
|
|
||||||
{fu.scheduledAt && (
|
|
||||||
<span className="text-[11px] text-tertiary ml-1.5">
|
|
||||||
{formatShortDate(fu.scheduledAt)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Badge size="sm" color={fu.followUpStatus === 'OVERDUE' ? 'error' : 'gray'} type="pill-color">
|
|
||||||
{fu.followUpStatus?.replace(/_/g, ' ') ?? 'Pending'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{linkedPatient && (
|
|
||||||
<div className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
|
|
||||||
<FontAwesomeIcon icon={faPhone} className="size-3 text-fg-success-primary shrink-0" />
|
|
||||||
<span className="text-xs text-primary">
|
|
||||||
Patient: <span className="font-medium">{linkedPatient.fullName?.firstName} {linkedPatient.fullName?.lastName}</span>
|
|
||||||
</span>
|
|
||||||
{linkedPatient.patientType && (
|
|
||||||
<Badge size="sm" color="gray" type="pill-color" className="ml-auto">{linkedPatient.patientType}</Badge>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(`/patient/${linkedPatient.id}`)}
|
|
||||||
className="text-[11px] font-medium text-brand-secondary hover:text-brand-secondary_hover shrink-0"
|
|
||||||
>
|
|
||||||
View 360
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recent calls + activities */}
|
|
||||||
{(leadCalls.length > 0 || leadActivities.length > 0) && (
|
|
||||||
<div>
|
|
||||||
<SectionHeader
|
|
||||||
icon={faClockRotateLeft}
|
|
||||||
label="Recent"
|
|
||||||
count={leadCalls.length + leadActivities.length}
|
|
||||||
expanded={recentExpanded}
|
|
||||||
onToggle={() => setRecentExpanded(!recentExpanded)}
|
|
||||||
/>
|
|
||||||
{recentExpanded && (
|
|
||||||
<div className="space-y-0.5 mb-1">
|
|
||||||
{leadCalls.map(call => (
|
|
||||||
<div key={call.id} className="flex items-center gap-2 py-1.5 px-1">
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={call.callStatus === 'MISSED' ? faPhoneMissed : call.callDirection === 'INBOUND' ? faPhoneArrowDown : faPhoneArrowUp}
|
|
||||||
className={cx('size-3 shrink-0',
|
|
||||||
call.callStatus === 'MISSED' ? 'text-fg-error-primary' :
|
|
||||||
call.callDirection === 'INBOUND' ? 'text-fg-success-secondary' : 'text-fg-brand-secondary'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<span className="text-xs text-primary">
|
|
||||||
{call.callStatus === 'MISSED' ? 'Missed' : call.callDirection === 'INBOUND' ? 'Inbound' : 'Outbound'} call
|
|
||||||
</span>
|
|
||||||
{call.durationSeconds != null && call.durationSeconds > 0 && (
|
|
||||||
<span className="text-[11px] text-tertiary ml-1">— {formatDuration(call.durationSeconds)}</span>
|
|
||||||
)}
|
|
||||||
{call.disposition && (
|
|
||||||
<span className="text-[11px] text-tertiary ml-1">, {call.disposition.replace(/_/g, ' ')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-[11px] text-quaternary shrink-0">
|
|
||||||
{formatTimeAgo(call.startedAt ?? call.createdAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{leadActivities
|
|
||||||
.filter(a => !leadCalls.some(c => a.summary?.includes(c.callerNumber?.[0]?.number ?? '---')))
|
|
||||||
.slice(0, 3)
|
|
||||||
.map(a => (
|
|
||||||
<div key={a.id} className="flex items-center gap-2 py-1.5 px-1">
|
|
||||||
<span className="size-1.5 rounded-full bg-fg-quaternary shrink-0" />
|
|
||||||
<span className="text-xs text-tertiary truncate flex-1">{a.summary}</span>
|
|
||||||
{a.occurredAt && (
|
|
||||||
<span className="text-[11px] text-quaternary shrink-0">{formatTimeAgo(a.occurredAt)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* No context available */}
|
|
||||||
{!hasContext && (
|
|
||||||
<p className="text-xs text-quaternary py-2">No history for this lead yet.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* AI Chat — fills remaining space */}
|
|
||||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
<AiChatPanel callerContext={callerContext} onChatStart={handleChatStart} />
|
<AiChatPanel callerContext={callerContext} callerSummary={callerSummary} onChatStart={handleChatStart} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user