mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: add worklist with missed calls, follow-ups, and leads sections
This commit is contained in:
277
src/components/call-desk/ai-chat-panel.tsx
Normal file
277
src/components/call-desk/ai-chat-panel.tsx
Normal file
@@ -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<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 pb-3">
|
||||
<FontAwesomeIcon icon={faSparkles} className="size-4 text-fg-brand-primary" />
|
||||
<h3 className="text-sm font-bold text-primary">AI Assistant</h3>
|
||||
</div>
|
||||
|
||||
{/* Caller context banner */}
|
||||
{callerContext?.leadName && (
|
||||
<div className="mb-3 rounded-lg bg-brand-primary px-3 py-2">
|
||||
<span className="text-xs text-brand-secondary">
|
||||
Talking to: <span className="font-semibold">{callerContext.leadName}</span>
|
||||
{callerContext.callerPhone ? ` (${callerContext.callerPhone})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick ask buttons */}
|
||||
{messages.length === 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||
{QUICK_ASK_BUTTONS.map((btn) => (
|
||||
<button
|
||||
key={btn.label}
|
||||
onClick={() => handleQuickAsk(btn.template)}
|
||||
disabled={isLoading}
|
||||
className="rounded-lg border border-secondary bg-primary px-2.5 py-1.5 text-xs font-medium text-secondary transition duration-100 ease-linear hover:bg-secondary hover:text-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages area */}
|
||||
<div className="flex-1 space-y-3 overflow-y-auto">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<FontAwesomeIcon icon={faRobot} className="mb-3 size-8 text-fg-quaternary" />
|
||||
<p className="text-sm text-tertiary">
|
||||
Ask me about doctors, clinics, packages, or patient info.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<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' && (
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<FontAwesomeIcon icon={faSparkles} className="size-2.5 text-fg-brand-primary" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-brand-secondary">AI</span>
|
||||
</div>
|
||||
)}
|
||||
<MessageContent content={msg.content} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-xl bg-secondary px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-fg-quaternary [animation-delay:0ms]" />
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-fg-quaternary [animation-delay:150ms]" />
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-fg-quaternary [animation-delay:300ms]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<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"
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => sendMessage()}
|
||||
disabled={isLoading || input.trim().length === 0}
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-brand-solid text-white transition duration-100 ease-linear hover:bg-brand-solid_hover disabled:cursor-not-allowed disabled:bg-disabled"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPaperPlaneTop} className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 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(
|
||||
<strong key={match.index} className="font-semibold">
|
||||
{match[1]}
|
||||
</strong>,
|
||||
);
|
||||
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 (
|
||||
<div className="space-y-1">
|
||||
{lines.map((line, i) => {
|
||||
if (line.trim().length === 0) return <div key={i} className="h-1" />;
|
||||
|
||||
// Bullet points
|
||||
if (line.trimStart().startsWith('- ')) {
|
||||
return (
|
||||
<div key={i} className="flex gap-1.5 pl-1">
|
||||
<span className="mt-1.5 size-1 shrink-0 rounded-full bg-fg-quaternary" />
|
||||
<span>{parseLine(line.replace(/^\s*-\s*/, ''))}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <p key={i}>{parseLine(line)}</p>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
115
src/hooks/use-worklist.ts
Normal file
115
src/hooks/use-worklist.ts
Normal file
@@ -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<WorklistData>(EMPTY_WORKLIST);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 };
|
||||
};
|
||||
@@ -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<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
||||
<FontAwesomeIcon icon={faPhoneXmark} className={className} />
|
||||
);
|
||||
const IconBell: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
||||
<FontAwesomeIcon icon={faBell} className={className} />
|
||||
);
|
||||
const IconUsers: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
||||
<FontAwesomeIcon icon={faUsers} className={className} />
|
||||
);
|
||||
const IconPhoneOutgoing: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
||||
<FontAwesomeIcon icon={faPhoneArrowUp} className={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<HTMLAttributes<HTMLOrSVGElement>>;
|
||||
title: string;
|
||||
count: number;
|
||||
badgeColor: 'error' | 'blue' | 'brand';
|
||||
}) => (
|
||||
<div className="flex items-center gap-2 border-b border-secondary px-5 py-3">
|
||||
<Icon className="size-4 text-fg-quaternary" />
|
||||
<h3 className="text-sm font-bold text-primary">{title}</h3>
|
||||
{count > 0 && (
|
||||
<Badge size="sm" color={badgeColor} type="pill-color">
|
||||
{count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<TopBar title="Call Desk" subtitle={`${user.name} \u00B7 Global Hospital`} />
|
||||
@@ -41,17 +111,167 @@ export const CallDeskPage = () => {
|
||||
>
|
||||
{isRegistered ? 'Phone Ready' : `Phone: ${connectionStatus}`}
|
||||
</BadgeWithDot>
|
||||
|
||||
{hasSidecarData && totalPending > 0 && (
|
||||
<Badge size="sm" color="brand" type="pill-color">
|
||||
{totalPending} pending
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{error !== null && (
|
||||
<Badge size="sm" color="warning" type="pill-color">
|
||||
Offline mode
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Worklist: leads with click-to-call */}
|
||||
{leads.length > 0 && (
|
||||
{/* Section 1: Missed Calls (highest priority) */}
|
||||
{hasSidecarData && missedCalls.length > 0 && (
|
||||
<div className="rounded-2xl border border-error bg-primary">
|
||||
<SectionHeader icon={IconPhoneMissed} title="Missed Calls" count={missedCalls.length} badgeColor="error" />
|
||||
<div className="divide-y divide-secondary">
|
||||
{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 (
|
||||
<div key={call.id} className="flex items-center justify-between gap-3 px-5 py-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-primary">{phoneDisplay}</span>
|
||||
<BadgeWithDot color={slaColor} size="sm" type="pill-color">
|
||||
{formatAge(minutesAgo)}
|
||||
</BadgeWithDot>
|
||||
</div>
|
||||
{call.startedAt !== null && (
|
||||
<p className="text-xs text-tertiary">
|
||||
{new Date(call.startedAt).toLocaleTimeString('en-IN', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ClickToCallButton phoneNumber={phoneNumber} label="Call Back" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 2: Follow-ups Due */}
|
||||
{hasSidecarData && followUps.length > 0 && (
|
||||
<div className="rounded-2xl border border-secondary bg-primary">
|
||||
<SectionHeader icon={IconBell} title="Follow-ups" count={followUps.length} badgeColor="blue" />
|
||||
<div className="divide-y divide-secondary">
|
||||
{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 (
|
||||
<div
|
||||
key={followUp.id}
|
||||
className={`flex items-center justify-between gap-3 px-5 py-3 ${isOverdue ? 'bg-error-primary/5' : ''}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-primary">
|
||||
{followUp.followUpType ?? 'Follow-up'}
|
||||
</span>
|
||||
{isOverdue && (
|
||||
<Badge size="sm" color="error" type="pill-color">
|
||||
Overdue
|
||||
</Badge>
|
||||
)}
|
||||
{followUp.priority === 'HIGH' || followUp.priority === 'URGENT' ? (
|
||||
<Badge size="sm" color="warning" type="pill-color">
|
||||
{followUp.priority}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-tertiary">{scheduledDisplay}</p>
|
||||
</div>
|
||||
<ClickToCallButton phoneNumber="" label="Call" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 3: Marketing Leads (from sidecar or fallback) */}
|
||||
{hasSidecarData && marketingLeads.length > 0 && (
|
||||
<div className="rounded-2xl border border-secondary bg-primary">
|
||||
<SectionHeader icon={IconUsers} title="Assigned Leads" count={marketingLeads.length} badgeColor="brand" />
|
||||
<div className="divide-y divide-secondary">
|
||||
{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 (
|
||||
<div key={lead.id} className="flex items-center justify-between gap-3 px-5 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-primary">{fullName}</span>
|
||||
<span className="text-sm text-tertiary">{phoneDisplay}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-quaternary">
|
||||
{lead.leadSource !== null && <span>{lead.leadSource}</span>}
|
||||
{lead.interestedService !== null && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{lead.interestedService}</span>
|
||||
</>
|
||||
)}
|
||||
{daysSinceCreated > 0 && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{daysSinceCreated}d old</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ClickToCallButton phoneNumber={phoneNumber} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback: show DataProvider leads when sidecar is unavailable */}
|
||||
{showFallbackLeads && (
|
||||
<div className="rounded-2xl border border-secondary bg-primary">
|
||||
<div className="border-b border-secondary px-5 py-3">
|
||||
<h3 className="text-sm font-bold text-primary">Worklist</h3>
|
||||
<p className="text-xs text-tertiary">Click to start an outbound call</p>
|
||||
</div>
|
||||
<div className="divide-y divide-secondary">
|
||||
{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 = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Incoming call card — driven by SIP phone state via the floating CallWidget */}
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<div className="rounded-2xl border border-secondary bg-primary px-5 py-8 text-center">
|
||||
<p className="text-sm text-tertiary">Loading worklist...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{hasSidecarData && missedCalls.length === 0 && followUps.length === 0 && marketingLeads.length === 0 && !loading && (
|
||||
<div className="rounded-2xl border border-secondary bg-primary px-5 py-8 text-center">
|
||||
<IconPhoneOutgoing className="mx-auto mb-2 size-6 text-fg-quaternary" />
|
||||
<p className="text-sm font-semibold text-primary">All clear</p>
|
||||
<p className="text-xs text-tertiary">No pending items in your worklist</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Incoming call card */}
|
||||
<IncomingCallCard
|
||||
callState="idle"
|
||||
lead={null}
|
||||
|
||||
Reference in New Issue
Block a user