diff --git a/src/components/call-desk/ai-chat-panel.tsx b/src/components/call-desk/ai-chat-panel.tsx new file mode 100644 index 0000000..b60698b --- /dev/null +++ b/src/components/call-desk/ai-chat-panel.tsx @@ -0,0 +1,277 @@ +import type { ReactNode } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faPaperPlaneTop, faRobot, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons'; +import { apiClient } from '@/lib/api-client'; + +const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; + +type ChatMessage = { + id: string; + role: 'user' | 'assistant'; + content: string; + timestamp: Date; +}; + +type CallerContext = { + callerPhone?: string; + leadId?: string; + leadName?: string; +}; + +interface AiChatPanelProps { + callerContext?: CallerContext; +} + +const QUICK_ASK_BUTTONS = [ + { label: 'Doctor availability', template: 'What are the visiting hours for all doctors?' }, + { label: 'Clinic timings', template: 'What are the clinic locations and timings?' }, + { label: 'Patient history', template: 'Can you summarize this patient\'s history?' }, + { label: 'Treatment packages', template: 'What treatment packages are available?' }, +]; + +export const AiChatPanel = ({ callerContext }: AiChatPanelProps) => { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + + const scrollToBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, []); + + useEffect(() => { + scrollToBottom(); + }, [messages, scrollToBottom]); + + const sendMessage = useCallback(async (text?: string) => { + const messageText = (text ?? input).trim(); + if (messageText.length === 0 || isLoading) return; + + const userMessage: ChatMessage = { + id: `user-${Date.now()}`, + role: 'user', + content: messageText, + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, userMessage]); + setInput(''); + setIsLoading(true); + + try { + const token = apiClient.getStoredToken(); + const response = await fetch(`${API_URL}/api/ai/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + message: messageText, + context: callerContext, + }), + }); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + const data = await response.json(); + + const assistantMessage: ChatMessage = { + id: `assistant-${Date.now()}`, + role: 'assistant', + content: data.reply ?? 'Sorry, I could not process that request.', + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, assistantMessage]); + } catch { + const errorMessage: ChatMessage = { + id: `error-${Date.now()}`, + role: 'assistant', + content: 'Sorry, I\'m having trouble connecting to the AI service. Please try again.', + timestamp: new Date(), + }; + setMessages((prev) => [...prev, errorMessage]); + } finally { + setIsLoading(false); + inputRef.current?.focus(); + } + }, [input, isLoading, callerContext]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }, [sendMessage]); + + const handleQuickAsk = useCallback((template: string) => { + sendMessage(template); + }, [sendMessage]); + + return ( +
+ {/* Header */} +
+ +

AI Assistant

+
+ + {/* Caller context banner */} + {callerContext?.leadName && ( +
+ + Talking to: {callerContext.leadName} + {callerContext.callerPhone ? ` (${callerContext.callerPhone})` : ''} + +
+ )} + + {/* Quick ask buttons */} + {messages.length === 0 && ( +
+ {QUICK_ASK_BUTTONS.map((btn) => ( + + ))} +
+ )} + + {/* Messages area */} +
+ {messages.length === 0 && ( +
+ +

+ Ask me about doctors, clinics, packages, or patient info. +

+
+ )} + + {messages.map((msg) => ( +
+
+ {msg.role === 'assistant' && ( +
+ + AI +
+ )} + +
+
+ ))} + + {isLoading && ( +
+
+
+ + + +
+
+
+ )} + +
+
+ + {/* Input area */} +
+
+ + setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Ask the AI assistant..." + disabled={isLoading} + className="flex-1 bg-transparent px-2 py-2 text-xs text-primary placeholder:text-placeholder outline-none disabled:cursor-not-allowed" + /> +
+ +
+
+ ); +}; + +// Parse simple markdown-like text into React nodes (safe, no innerHTML) +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]; +}; + +const MessageContent = ({ content }: { content: string }) => { + const lines = content.split('\n'); + + return ( +
+ {lines.map((line, i) => { + if (line.trim().length === 0) return
; + + // Bullet points + if (line.trimStart().startsWith('- ')) { + return ( +
+ + {parseLine(line.replace(/^\s*-\s*/, ''))} +
+ ); + } + + return

{parseLine(line)}

; + })} +
+ ); +}; diff --git a/src/hooks/use-worklist.ts b/src/hooks/use-worklist.ts new file mode 100644 index 0000000..3ea4385 --- /dev/null +++ b/src/hooks/use-worklist.ts @@ -0,0 +1,115 @@ +import { useState, useEffect, useCallback } from 'react'; + +import { apiClient } from '@/lib/api-client'; + +type MissedCall = { + id: string; + createdAt: string; + callDirection: string | null; + callStatus: string | null; + callerNumber: { number: string; callingCode: string }[] | null; + agentName: string | null; + startedAt: string | null; + endedAt: string | null; + durationSeconds: number | null; + disposition: string | null; + callNotes: string | null; + leadId: string | null; +}; + +type WorklistFollowUp = { + id: string; + createdAt: string | null; + followUpType: string | null; + followUpStatus: string | null; + scheduledAt: string | null; + completedAt: string | null; + priority: string | null; + assignedAgent: string | null; + patientId: string | null; + callId: string | null; +}; + +type WorklistLead = { + id: string; + createdAt: string; + contactName: { firstName: string; lastName: string } | null; + contactPhone: { number: string; callingCode: string }[] | null; + contactEmail: { address: string }[] | null; + leadSource: string | null; + leadStatus: string | null; + interestedService: string | null; + assignedAgent: string | null; + campaignId: string | null; + adId: string | null; + contactAttempts: number | null; + spamScore: number | null; + isSpam: boolean | null; + aiSummary: string | null; + aiSuggestedAction: string | null; +}; + +type WorklistData = { + missedCalls: MissedCall[]; + followUps: WorklistFollowUp[]; + marketingLeads: WorklistLead[]; + totalPending: number; +}; + +type UseWorklistResult = WorklistData & { + loading: boolean; + error: string | null; + refresh: () => void; +}; + +const EMPTY_WORKLIST: WorklistData = { + missedCalls: [], + followUps: [], + marketingLeads: [], + totalPending: 0, +}; + +export const useWorklist = (): UseWorklistResult => { + const [data, setData] = useState(EMPTY_WORKLIST); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchWorklist = useCallback(async () => { + try { + const token = apiClient.getStoredToken(); + if (!token) { + setError('Not authenticated'); + setLoading(false); + return; + } + + const apiUrl = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; + const response = await fetch(`${apiUrl}/api/worklist`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (response.ok) { + const json = await response.json(); + setData(json); + setError(null); + } else { + setError(`Worklist API returned ${response.status}`); + } + } catch (err) { + console.warn('Worklist fetch failed:', err); + setError('Sidecar not reachable'); + } + + setLoading(false); + }, []); + + useEffect(() => { + fetchWorklist(); + + // Refresh every 30 seconds + const interval = setInterval(fetchWorklist, 30000); + return () => clearInterval(interval); + }, [fetchWorklist]); + + return { ...data, loading, error, refresh: fetchWorklist }; +}; diff --git a/src/pages/call-desk.tsx b/src/pages/call-desk.tsx index 4e5ed0a..65aaaee 100644 --- a/src/pages/call-desk.tsx +++ b/src/pages/call-desk.tsx @@ -1,31 +1,101 @@ +import { useState } from 'react'; +import type { FC, HTMLAttributes } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faPhoneXmark, faBell, faUsers, faPhoneArrowUp, faSparkles, faChartSimple } from '@fortawesome/pro-duotone-svg-icons'; import { useAuth } from '@/providers/auth-provider'; import { useData } from '@/providers/data-provider'; import { useLeads } from '@/hooks/use-leads'; +import { useWorklist } from '@/hooks/use-worklist'; import { useSip } from '@/providers/sip-provider'; import { TopBar } from '@/components/layout/top-bar'; import { IncomingCallCard } from '@/components/call-desk/incoming-call-card'; import { ClickToCallButton } from '@/components/call-desk/click-to-call-button'; import { CallLog } from '@/components/call-desk/call-log'; import { DailyStats } from '@/components/call-desk/daily-stats'; -import { BadgeWithDot } from '@/components/base/badges/badges'; +import { AiChatPanel } from '@/components/call-desk/ai-chat-panel'; +import { Badge, BadgeWithDot } from '@/components/base/badges/badges'; import { formatPhone } from '@/lib/format'; +// FA icon wrappers compatible with Untitled UI component props +const IconPhoneMissed: FC> = ({ className }) => ( + +); +const IconBell: FC> = ({ className }) => ( + +); +const IconUsers: FC> = ({ className }) => ( + +); +const IconPhoneOutgoing: FC> = ({ className }) => ( + +); + const isToday = (dateStr: string): boolean => { const d = new Date(dateStr); const now = new Date(); return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate(); }; +// Calculate minutes since a given ISO timestamp +const minutesSince = (dateStr: string): number => { + return Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000)); +}; + +// SLA color based on minutes elapsed +const getSlaColor = (minutes: number): 'success' | 'warning' | 'error' => { + if (minutes < 15) return 'success'; + if (minutes <= 30) return 'warning'; + return 'error'; +}; + +// Format minutes into a readable age string +const formatAge = (minutes: number): string => { + 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 ${minutes % 60}m ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +}; + +// Section header with count badge +const SectionHeader = ({ + icon: Icon, + title, + count, + badgeColor, +}: { + icon: FC>; + title: string; + count: number; + badgeColor: 'error' | 'blue' | 'brand'; +}) => ( +
+ +

{title}

+ {count > 0 && ( + + {count} + + )} +
+); + export const CallDeskPage = () => { const { user } = useAuth(); const { calls, leadActivities, campaigns } = useData(); - const { leads } = useLeads(); + const { leads: fallbackLeads } = useLeads(); const { connectionStatus, isRegistered } = useSip(); + const { missedCalls, followUps, marketingLeads, totalPending, loading, error } = useWorklist(); const todaysCalls = calls.filter( (c) => c.agentName === user.name && c.startedAt !== null && isToday(c.startedAt), ); + // When sidecar is unavailable, show fallback leads from DataProvider + const hasSidecarData = error === null && !loading; + const showFallbackLeads = !hasSidecarData && fallbackLeads.length > 0; + return (
@@ -41,17 +111,167 @@ export const CallDeskPage = () => { > {isRegistered ? 'Phone Ready' : `Phone: ${connectionStatus}`} + + {hasSidecarData && totalPending > 0 && ( + + {totalPending} pending + + )} + + {error !== null && ( + + Offline mode + + )}
- {/* Worklist: leads with click-to-call */} - {leads.length > 0 && ( + {/* Section 1: Missed Calls (highest priority) */} + {hasSidecarData && missedCalls.length > 0 && ( +
+ +
+ {missedCalls.map((call) => { + const callerPhone = call.callerNumber?.[0]; + const phoneDisplay = callerPhone ? formatPhone(callerPhone) : 'Unknown'; + const phoneNumber = callerPhone?.number ?? ''; + const minutesAgo = call.createdAt ? minutesSince(call.createdAt) : 0; + const slaColor = getSlaColor(minutesAgo); + + return ( +
+
+
+
+ {phoneDisplay} + + {formatAge(minutesAgo)} + +
+ {call.startedAt !== null && ( +

+ {new Date(call.startedAt).toLocaleTimeString('en-IN', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + })} +

+ )} +
+
+ +
+ ); + })} +
+
+ )} + + {/* Section 2: Follow-ups Due */} + {hasSidecarData && followUps.length > 0 && ( +
+ +
+ {followUps.map((followUp) => { + const isOverdue = + followUp.followUpStatus === 'OVERDUE' || + (followUp.scheduledAt !== null && new Date(followUp.scheduledAt) < new Date()); + const scheduledDisplay = followUp.scheduledAt + ? new Date(followUp.scheduledAt).toLocaleString('en-IN', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }) + : 'Not scheduled'; + + return ( +
+
+
+ + {followUp.followUpType ?? 'Follow-up'} + + {isOverdue && ( + + Overdue + + )} + {followUp.priority === 'HIGH' || followUp.priority === 'URGENT' ? ( + + {followUp.priority} + + ) : null} +
+

{scheduledDisplay}

+
+ +
+ ); + })} +
+
+ )} + + {/* Section 3: Marketing Leads (from sidecar or fallback) */} + {hasSidecarData && marketingLeads.length > 0 && ( +
+ +
+ {marketingLeads.map((lead) => { + const firstName = lead.contactName?.firstName ?? ''; + const lastName = lead.contactName?.lastName ?? ''; + const fullName = `${firstName} ${lastName}`.trim() || 'Unknown'; + const phone = lead.contactPhone?.[0]; + const phoneDisplay = phone ? formatPhone(phone) : 'No phone'; + const phoneNumber = phone?.number ?? ''; + const daysSinceCreated = lead.createdAt + ? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24)) + : 0; + + return ( +
+
+
+ {fullName} + {phoneDisplay} +
+
+ {lead.leadSource !== null && {lead.leadSource}} + {lead.interestedService !== null && ( + <> + · + {lead.interestedService} + + )} + {daysSinceCreated > 0 && ( + <> + · + {daysSinceCreated}d old + + )} +
+
+ +
+ ); + })} +
+
+ )} + + {/* Fallback: show DataProvider leads when sidecar is unavailable */} + {showFallbackLeads && (

Worklist

Click to start an outbound call

- {leads.slice(0, 10).map((lead) => { + {fallbackLeads.slice(0, 10).map((lead) => { const firstName = lead.contactName?.firstName ?? ''; const lastName = lead.contactName?.lastName ?? ''; const fullName = `${firstName} ${lastName}`.trim() || 'Unknown'; @@ -76,7 +296,23 @@ export const CallDeskPage = () => {
)} - {/* Incoming call card — driven by SIP phone state via the floating CallWidget */} + {/* Loading state */} + {loading && ( +
+

Loading worklist...

+
+ )} + + {/* Empty state */} + {hasSidecarData && missedCalls.length === 0 && followUps.length === 0 && marketingLeads.length === 0 && !loading && ( +
+ +

All clear

+

No pending items in your worklist

+
+ )} + + {/* Incoming call card */}