mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
feat: worklist sorting, contextual disposition, context panel redesign, notifications
- Worklist default sort descending (newest first), sortable column headers (PRIORITY, PATIENT, SLA) via React Aria - Contextual disposition: auto-selects based on in-call actions (appointment → APPOINTMENT_BOOKED, enquiry → INFO_PROVIDED, transfer → FOLLOW_UP_SCHEDULED) - Context panel redesign: collapsible AI Insight, Upcoming (appointments + follow-ups + linked patient), Recent (calls + activities) sections; auto-collapse on AI chat start - Appointments added to DataProvider with APPOINTMENTS_QUERY, Appointment type, transform - Notification bell for admin/supervisor: performance alerts (idle time, NPS, conversion thresholds) with toast on load + bell dropdown with dismiss; demo alerts as fallback - Slideout z-index fix: added z-50 to slideout ModalOverlay matching modal component Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ export const ModalOverlay = (props: ModalOverlayProps) => {
|
||||
{...props}
|
||||
className={(state) =>
|
||||
cx(
|
||||
"fixed inset-0 flex min-h-dvh w-full items-center justify-end bg-overlay/70 pl-6 outline-hidden ease-linear md:pl-10",
|
||||
"fixed inset-0 z-50 flex min-h-dvh w-full items-center justify-end bg-overlay/70 pl-6 outline-hidden ease-linear md:pl-10",
|
||||
state.isEntering && "duration-300 animate-in fade-in",
|
||||
state.isExiting && "duration-500 animate-out fade-out",
|
||||
typeof props.className === "function" ? props.className(state) : props.className,
|
||||
@@ -81,7 +81,7 @@ const Menu = ({ children, dialogClassName, ...props }: SlideoutMenuProps) => {
|
||||
Menu.displayName = "SlideoutMenu";
|
||||
|
||||
const Content = ({ role = "main", ...props }: ComponentPropsWithRef<"div">) => {
|
||||
return <div role={role} {...props} className={cx("flex size-full flex-col gap-6 overflow-y-auto overscroll-auto px-4 md:px-6", props.className)} />;
|
||||
return <div role={role} {...props} className={cx("flex flex-1 min-h-0 flex-col gap-6 overflow-y-auto overscroll-auto px-4 md:px-6", props.className)} />;
|
||||
};
|
||||
Content.displayName = "SlideoutContent";
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
const [enquiryOpen, setEnquiryOpen] = useState(false);
|
||||
const [dispositionOpen, setDispositionOpen] = useState(false);
|
||||
const [callerDisconnected, setCallerDisconnected] = useState(false);
|
||||
const [suggestedDisposition, setSuggestedDisposition] = useState<CallDisposition | null>(null);
|
||||
|
||||
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
||||
const wasAnsweredRef = useRef(callState === 'active');
|
||||
@@ -118,6 +119,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
|
||||
const handleAppointmentSaved = () => {
|
||||
setAppointmentOpen(false);
|
||||
setSuggestedDisposition('APPOINTMENT_BOOKED');
|
||||
notify.success('Appointment Booked', 'Payment link will be sent to the patient');
|
||||
};
|
||||
|
||||
@@ -201,7 +203,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
wasAnsweredRef.current = true;
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-xl border border-brand bg-primary p-4">
|
||||
<div className="flex flex-col rounded-xl border border-brand bg-primary overflow-hidden">
|
||||
{/* Pinned: caller info + controls */}
|
||||
<div className="shrink-0 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-success-solid">
|
||||
@@ -276,12 +280,16 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
onClose={() => setTransferOpen(false)}
|
||||
onTransferred={() => {
|
||||
setTransferOpen(false);
|
||||
setSuggestedDisposition('FOLLOW_UP_SCHEDULED');
|
||||
setDispositionOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Appointment form */}
|
||||
{/* Scrollable: expanded forms */}
|
||||
{(appointmentOpen || enquiryOpen) && (
|
||||
<div className="min-h-0 max-h-[50vh] overflow-y-auto border-t border-secondary px-4 pb-4">
|
||||
<AppointmentForm
|
||||
isOpen={appointmentOpen}
|
||||
onOpenChange={setAppointmentOpen}
|
||||
@@ -292,23 +300,26 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
onSaved={handleAppointmentSaved}
|
||||
/>
|
||||
|
||||
{/* Enquiry form */}
|
||||
<EnquiryForm
|
||||
isOpen={enquiryOpen}
|
||||
onOpenChange={setEnquiryOpen}
|
||||
callerPhone={callerPhone}
|
||||
onSaved={() => {
|
||||
setEnquiryOpen(false);
|
||||
setSuggestedDisposition('INFO_PROVIDED');
|
||||
notify.success('Enquiry Logged');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Disposition Modal — the ONLY path to end a call */}
|
||||
<DispositionModal
|
||||
isOpen={dispositionOpen}
|
||||
callerName={fullName || phoneDisplay}
|
||||
callerDisconnected={callerDisconnected}
|
||||
defaultDisposition={suggestedDisposition}
|
||||
onSubmit={handleDisposition}
|
||||
onDismiss={() => {
|
||||
// Agent wants to continue the call — close modal, call stays active
|
||||
|
||||
@@ -63,12 +63,18 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
|
||||
}
|
||||
};
|
||||
|
||||
// If SIP isn't connected, show connection status
|
||||
// If SIP isn't connected, show connection status with user-friendly message
|
||||
if (!isRegistered) {
|
||||
const statusMessages: Record<string, string> = {
|
||||
disconnected: 'Telephony unavailable',
|
||||
connecting: 'Connecting to telephony...',
|
||||
connected: 'Registering...',
|
||||
error: 'Telephony error — check VPN',
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1">
|
||||
<FontAwesomeIcon icon={faCircle} className="size-2 text-fg-warning-primary animate-pulse" />
|
||||
<span className="text-xs font-medium text-tertiary">{connectionStatus}</span>
|
||||
<span className="text-xs font-medium text-tertiary">{statusMessages[connectionStatus] ?? connectionStatus}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPaperPlaneTop, faSparkles, faUserHeadset, faUser, faCalendarCheck, faStethoscope } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faPaperPlaneTop, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||
|
||||
@@ -14,6 +14,7 @@ type CallerContext = {
|
||||
|
||||
interface AiChatPanelProps {
|
||||
callerContext?: CallerContext;
|
||||
onChatStart?: () => void;
|
||||
}
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
@@ -23,13 +24,15 @@ const QUICK_ACTIONS = [
|
||||
{ label: 'Treatment packages', prompt: 'What treatment packages are available?' },
|
||||
];
|
||||
|
||||
export const AiChatPanel = ({ callerContext }: AiChatPanelProps) => {
|
||||
export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const chatStartedRef = useRef(false);
|
||||
|
||||
const token = localStorage.getItem('helix_access_token') ?? '';
|
||||
|
||||
const { messages, input, handleSubmit, handleInputChange, isLoading, append } = useChat({
|
||||
api: `${API_URL}/api/ai/stream`,
|
||||
streamProtocol: 'text',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
@@ -43,7 +46,11 @@ export const AiChatPanel = ({ callerContext }: AiChatPanelProps) => {
|
||||
if (el?.parentElement) {
|
||||
el.parentElement.scrollTop = el.parentElement.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
if (messages.length > 0 && !chatStartedRef.current) {
|
||||
chatStartedRef.current = true;
|
||||
onChatStart?.();
|
||||
}
|
||||
}, [messages, onChatStart]);
|
||||
|
||||
const handleQuickAction = (prompt: string) => {
|
||||
append({ role: 'user', content: prompt });
|
||||
@@ -92,10 +99,6 @@ export const AiChatPanel = ({ callerContext }: AiChatPanelProps) => {
|
||||
</div>
|
||||
)}
|
||||
<MessageContent content={msg.content} />
|
||||
|
||||
{msg.parts?.filter((p: any) => p.type === 'tool-invocation').map((part: any, i: number) => (
|
||||
<ToolResultCard key={i} toolName={part.toolInvocation?.toolName} state={part.toolInvocation?.state} result={part.toolInvocation?.result} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -138,97 +141,7 @@ export const AiChatPanel = ({ callerContext }: AiChatPanelProps) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolResultCard = ({ toolName, state, result }: { toolName: string; state: string; result: any }) => {
|
||||
if (state !== 'result' || !result) return null;
|
||||
|
||||
switch (toolName) {
|
||||
case 'lookup_patient':
|
||||
if (!result.found) return null;
|
||||
return (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{result.leads?.map((lead: any) => (
|
||||
<div key={lead.id} className="rounded-lg border border-secondary bg-primary p-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FontAwesomeIcon icon={faUser} className="size-3 text-fg-brand-primary" />
|
||||
<span className="text-xs font-semibold text-primary">
|
||||
{lead.contactName?.firstName} {lead.contactName?.lastName}
|
||||
</span>
|
||||
{lead.status && (
|
||||
<span className="ml-auto rounded-full bg-brand-primary px-1.5 py-0.5 text-[10px] font-medium text-brand-secondary">
|
||||
{lead.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{lead.contactPhone?.primaryPhoneNumber && (
|
||||
<p className="mt-0.5 text-[10px] text-tertiary">{lead.contactPhone.primaryPhoneNumber}</p>
|
||||
)}
|
||||
{lead.aiSummary && (
|
||||
<p className="mt-1 text-[10px] text-secondary italic">{lead.aiSummary}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'lookup_appointments':
|
||||
if (!result.appointments?.length) return null;
|
||||
return (
|
||||
<div className="mt-2 space-y-1">
|
||||
{result.appointments.map((appt: any) => (
|
||||
<div key={appt.id} className="flex items-center gap-1.5 rounded-md border border-secondary bg-primary px-2 py-1.5">
|
||||
<FontAwesomeIcon icon={faCalendarCheck} className="size-3 text-fg-brand-primary shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-[10px] font-semibold text-primary">
|
||||
{appt.doctorName ?? 'Doctor'} . {appt.department ?? ''}
|
||||
</span>
|
||||
<span className="ml-1 text-[10px] text-quaternary">
|
||||
{appt.scheduledAt ? new Date(appt.scheduledAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : ''}
|
||||
</span>
|
||||
</div>
|
||||
{appt.status && (
|
||||
<span className="rounded-full bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary">
|
||||
{appt.status.toLowerCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'lookup_doctor':
|
||||
if (!result.found) return null;
|
||||
return (
|
||||
<div className="mt-2 space-y-1">
|
||||
{result.doctors?.map((doc: any) => (
|
||||
<div key={doc.id} className="rounded-lg border border-secondary bg-primary p-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FontAwesomeIcon icon={faStethoscope} className="size-3 text-fg-success-primary" />
|
||||
<span className="text-xs font-semibold text-primary">
|
||||
Dr. {doc.fullName?.firstName} {doc.fullName?.lastName}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[10px] text-tertiary">
|
||||
{doc.department} . {doc.specialty}
|
||||
</p>
|
||||
{doc.visitingHours && (
|
||||
<p className="text-[10px] text-secondary">Hours: {doc.visitingHours}</p>
|
||||
)}
|
||||
{doc.consultationFeeNew && (
|
||||
<p className="text-[10px] text-secondary">
|
||||
Fee: {'\u20B9'}{doc.consultationFeeNew.amountMicros / 1_000_000}
|
||||
{doc.clinic?.clinicName ? ` . ${doc.clinic.clinicName}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
// 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[] = [];
|
||||
|
||||
@@ -198,6 +198,12 @@ export const AppointmentForm = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
if (!isEditMode && date < today) {
|
||||
setError('Appointment date cannot be in the past.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
@@ -1,68 +1,74 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSparkles, faCalendarCheck, faPhone, faUser } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faIcon } from '@/lib/icon-wrapper';
|
||||
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 { apiClient } from '@/lib/api-client';
|
||||
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||
import type { Lead, LeadActivity } from '@/types/entities';
|
||||
|
||||
const CalendarCheck = faIcon(faCalendarCheck);
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { Lead, LeadActivity, Call, FollowUp, Patient, Appointment } from '@/types/entities';
|
||||
import { AppointmentForm } from './appointment-form';
|
||||
|
||||
interface ContextPanelProps {
|
||||
selectedLead: Lead | null;
|
||||
activities: LeadActivity[];
|
||||
calls: Call[];
|
||||
followUps: FollowUp[];
|
||||
appointments: Appointment[];
|
||||
patients: Patient[];
|
||||
callerPhone?: string;
|
||||
isInCall?: boolean;
|
||||
callUcid?: string | null;
|
||||
}
|
||||
|
||||
export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall }: ContextPanelProps) => {
|
||||
const [patientData, setPatientData] = useState<any>(null);
|
||||
const [loadingPatient, setLoadingPatient] = useState(false);
|
||||
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`;
|
||||
};
|
||||
|
||||
// Fetch patient data when lead has a patientId
|
||||
useEffect(() => {
|
||||
const patientId = (selectedLead as any)?.patientId;
|
||||
if (!patientId) {
|
||||
setPatientData(null);
|
||||
return;
|
||||
}
|
||||
const formatDuration = (sec: number): string => {
|
||||
if (sec < 60) return `${sec}s`;
|
||||
return `${Math.floor(sec / 60)}m ${sec % 60}s`;
|
||||
};
|
||||
|
||||
setLoadingPatient(true);
|
||||
apiClient.graphql<{ patients: { edges: Array<{ node: any }> } }>(
|
||||
`query GetPatient($id: UUID!) { patients(filter: { id: { eq: $id } }) { edges { node {
|
||||
id fullName { firstName lastName } dateOfBirth gender
|
||||
phones { primaryPhoneNumber } emails { primaryEmail }
|
||||
appointments(first: 5, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id scheduledAt status doctorName department reasonForVisit
|
||||
} } }
|
||||
calls(first: 5, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||
id callStatus disposition direction startedAt durationSec agentName
|
||||
} } }
|
||||
} } } }`,
|
||||
{ id: patientId },
|
||||
{ silent: true },
|
||||
).then(data => {
|
||||
setPatientData(data.patients.edges[0]?.node ?? null);
|
||||
}).catch(() => setPatientData(null))
|
||||
.finally(() => setLoadingPatient(false));
|
||||
}, [(selectedLead as any)?.patientId]);
|
||||
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 [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 lead = selectedLead;
|
||||
const firstName = lead?.contactName?.firstName ?? '';
|
||||
const lastName = lead?.contactName?.lastName ?? '';
|
||||
const fullName = `${firstName} ${lastName}`.trim();
|
||||
const phone = lead?.contactPhone?.[0];
|
||||
const email = lead?.contactEmail?.[0]?.address;
|
||||
|
||||
const leadActivities = activities
|
||||
.filter((a) => lead && a.leadId === lead.id)
|
||||
.sort((a, b) => new Date(b.occurredAt ?? b.createdAt ?? '').getTime() - new Date(a.occurredAt ?? a.createdAt ?? '').getTime())
|
||||
.slice(0, 10);
|
||||
|
||||
const appointments = patientData?.appointments?.edges?.map((e: any) => e.node) ?? [];
|
||||
|
||||
const callerContext = lead ? {
|
||||
callerPhone: phone?.number ?? callerPhone,
|
||||
@@ -70,115 +76,250 @@ export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall }
|
||||
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 as any)?.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 as any)?.patientId;
|
||||
if (!patientId) return [];
|
||||
return appointments
|
||||
.filter(a => a.patientId === patientId && a.appointmentStatus !== 'CANCELLED' && a.appointmentStatus !== 'NO_SHOW')
|
||||
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime())
|
||||
.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],
|
||||
);
|
||||
|
||||
// Linked patient
|
||||
const linkedPatient = useMemo(() =>
|
||||
patients.find(p => p.id === (lead as any)?.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);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Context header — shows caller/lead info when available */}
|
||||
{/* Lead header — always visible */}
|
||||
{lead && (
|
||||
<div className="shrink-0 border-b border-secondary p-4 space-y-3">
|
||||
{/* Call status banner */}
|
||||
<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 && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-success-primary px-3 py-2">
|
||||
<FontAwesomeIcon icon={faPhone} className="size-3 text-fg-success-primary" />
|
||||
<span className="text-xs font-semibold text-success-primary">
|
||||
On call with {fullName || callerPhone || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Lead profile */}
|
||||
{/* Expanded context sections */}
|
||||
{contextExpanded && (
|
||||
<div className="px-4 pb-3 space-y-1 overflow-y-auto" style={{ maxHeight: '50vh' }}>
|
||||
{/* AI Insight */}
|
||||
{lead.aiSummary && (
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-primary">{fullName || 'Unknown'}</h3>
|
||||
{phone && <p className="text-sm text-secondary">{formatPhone(phone)}</p>}
|
||||
{email && <p className="text-xs text-tertiary">{email}</p>}
|
||||
</div>
|
||||
|
||||
{/* Status badges */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{!!patientData && (
|
||||
<Badge size="sm" color="brand" type="pill-color">Returning Patient</Badge>
|
||||
)}
|
||||
{lead.leadStatus && <Badge size="sm" color="brand">{lead.leadStatus.replace(/_/g, ' ')}</Badge>}
|
||||
{lead.leadSource && <Badge size="sm" color="gray">{lead.leadSource.replace(/_/g, ' ')}</Badge>}
|
||||
</div>
|
||||
|
||||
{lead.interestedService && (
|
||||
<p className="text-xs text-secondary">Interested in: {lead.interestedService}</p>
|
||||
)}
|
||||
|
||||
{/* AI Insight — live from platform */}
|
||||
{(lead.aiSummary || lead.aiSuggestedAction) && (
|
||||
<div className="rounded-lg bg-brand-primary p-3">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<FontAwesomeIcon icon={faSparkles} className="size-3 text-fg-brand-primary" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-brand-secondary">AI Insight</span>
|
||||
</div>
|
||||
{lead.aiSummary && <p className="text-xs text-primary">{lead.aiSummary}</p>}
|
||||
<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 text-xs font-semibold text-brand-secondary">{lead.aiSuggestedAction}</p>
|
||||
<p className="mt-1.5 text-xs font-semibold text-brand-secondary">{lead.aiSuggestedAction}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upcoming appointments */}
|
||||
{appointments.length > 0 && (
|
||||
{/* Quick Actions — upcoming appointments + follow-ups + linked patient */}
|
||||
{(leadAppointments.length > 0 || leadFollowUps.length > 0 || linkedPatient) && (
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-tertiary uppercase">Appointments</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{appointments.slice(0, 3).map((appt: any) => (
|
||||
<div key={appt.id} className="flex items-center gap-2 rounded-md bg-secondary px-2 py-1.5">
|
||||
<CalendarCheck className="size-3 text-fg-brand-primary shrink-0" />
|
||||
<span className="text-xs text-primary truncate">
|
||||
{appt.doctorName ?? 'Doctor'} · {appt.scheduledAt ? formatShortDate(appt.scheduledAt) : ''}
|
||||
</span>
|
||||
{appt.status && (
|
||||
<Badge size="sm" color={appt.status === 'COMPLETED' ? 'success' : appt.status === 'CANCELLED' ? 'error' : 'brand'}>
|
||||
{appt.status.toLowerCase()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingPatient && <p className="text-[10px] text-quaternary">Loading patient details...</p>}
|
||||
|
||||
{/* Recent activity */}
|
||||
{leadActivities.length > 0 && (
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-tertiary uppercase">Recent Activity</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{leadActivities.slice(0, 5).map((a) => (
|
||||
<div key={a.id} className="flex items-start gap-2">
|
||||
<div className="mt-1.5 size-1.5 shrink-0 rounded-full bg-fg-quaternary" />
|
||||
<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">
|
||||
<p className="text-xs text-primary truncate">{a.summary}</p>
|
||||
<p className="text-[10px] text-quaternary">
|
||||
{a.activityType?.replace(/_/g, ' ')}{a.occurredAt ? ` · ${formatShortDate(a.occurredAt)}` : ''}
|
||||
</p>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No lead selected — empty state */}
|
||||
{!lead && (
|
||||
<div className="shrink-0 flex items-center justify-center border-b border-secondary px-4 py-6">
|
||||
<div className="text-center">
|
||||
<FontAwesomeIcon icon={faUser} className="mb-2 size-6 text-fg-quaternary" />
|
||||
<p className="text-xs text-tertiary">Select a lead from the worklist to see context</p>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* AI Chat — always available at the bottom */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<AiChatPanel callerContext={callerContext} />
|
||||
{/* 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">
|
||||
<AiChatPanel callerContext={callerContext} onChatStart={handleChatStart} />
|
||||
</div>
|
||||
|
||||
{/* Appointment edit form */}
|
||||
{editingAppointment && (
|
||||
<AppointmentForm
|
||||
isOpen={!!editingAppointment}
|
||||
onOpenChange={(open) => { if (!open) setEditingAppointment(null); }}
|
||||
callerNumber={callerPhone}
|
||||
leadName={fullName}
|
||||
leadId={lead?.id}
|
||||
patientId={editingAppointment.patientId}
|
||||
existingAppointment={{
|
||||
id: editingAppointment.id,
|
||||
scheduledAt: editingAppointment.scheduledAt ?? '',
|
||||
doctorName: editingAppointment.doctorName ?? '',
|
||||
doctorId: editingAppointment.doctorId ?? undefined,
|
||||
department: editingAppointment.department ?? '',
|
||||
reasonForVisit: editingAppointment.reasonForVisit ?? undefined,
|
||||
status: editingAppointment.appointmentStatus ?? 'SCHEDULED',
|
||||
}}
|
||||
onSaved={() => setEditingAppointment(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPhoneHangup } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
|
||||
@@ -60,19 +60,28 @@ type DispositionModalProps = {
|
||||
isOpen: boolean;
|
||||
callerName: string;
|
||||
callerDisconnected: boolean;
|
||||
defaultDisposition?: CallDisposition | null;
|
||||
onSubmit: (disposition: CallDisposition, notes: string) => void;
|
||||
onDismiss?: () => void;
|
||||
};
|
||||
|
||||
export const DispositionModal = ({ isOpen, callerName, callerDisconnected, onSubmit, onDismiss }: DispositionModalProps) => {
|
||||
export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defaultDisposition, onSubmit, onDismiss }: DispositionModalProps) => {
|
||||
const [selected, setSelected] = useState<CallDisposition | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
const appliedDefaultRef = useRef<CallDisposition | null | undefined>(undefined);
|
||||
|
||||
// Pre-select when modal opens with a suggestion
|
||||
if (isOpen && defaultDisposition && appliedDefaultRef.current !== defaultDisposition) {
|
||||
appliedDefaultRef.current = defaultDisposition;
|
||||
setSelected(defaultDisposition);
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (selected === null) return;
|
||||
onSubmit(selected, notes);
|
||||
setSelected(null);
|
||||
setNotes('');
|
||||
appliedDefaultRef.current = undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -73,7 +73,30 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Create a lead with source PHONE_INQUIRY
|
||||
// Resolve caller — ensures lead+patient pair exists, returns IDs
|
||||
let leadId: string | null = null;
|
||||
if (registeredPhone) {
|
||||
const resolved = await apiClient.post<{ leadId: string; patientId: string }>('/api/caller/resolve', { phone: registeredPhone }, { silent: true });
|
||||
leadId = resolved.leadId;
|
||||
}
|
||||
|
||||
if (leadId) {
|
||||
// Update existing lead with enquiry details
|
||||
await apiClient.graphql(
|
||||
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||
{
|
||||
id: leadId,
|
||||
data: {
|
||||
name: `Enquiry — ${patientName}`,
|
||||
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
|
||||
source: 'PHONE',
|
||||
status: disposition === 'CONVERTED' ? 'CONVERTED' : 'NEW',
|
||||
interestedService: queryAsked.substring(0, 100),
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// No phone provided — create a new lead (rare edge case)
|
||||
await apiClient.graphql(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{
|
||||
@@ -87,6 +110,7 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Create follow-up if needed
|
||||
if (followUpNeeded && followUpDate) {
|
||||
|
||||
@@ -9,9 +9,10 @@ type PhoneActionCellProps = {
|
||||
phoneNumber: string;
|
||||
displayNumber: string;
|
||||
leadId?: string;
|
||||
onDial?: () => void;
|
||||
};
|
||||
|
||||
export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }: PhoneActionCellProps) => {
|
||||
export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, onDial }: PhoneActionCellProps) => {
|
||||
const { isRegistered, isInCall, dialOutbound } = useSip();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [dialing, setDialing] = useState(false);
|
||||
@@ -35,6 +36,7 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }:
|
||||
setMenuOpen(false);
|
||||
setDialing(true);
|
||||
try {
|
||||
onDial?.();
|
||||
await dialOutbound(phoneNumber);
|
||||
} catch {
|
||||
notify.error('Dial Failed', 'Could not place the call');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { faPhoneArrowDown, faPhoneArrowUp, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import type { SortDescriptor } from 'react-aria-components';
|
||||
import { faIcon } from '@/lib/icon-wrapper';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
|
||||
@@ -60,6 +61,7 @@ interface WorklistPanelProps {
|
||||
loading: boolean;
|
||||
onSelectLead: (lead: WorklistLead) => void;
|
||||
selectedLeadId: string | null;
|
||||
onDialMissedCall?: (missedCallId: string) => void;
|
||||
}
|
||||
|
||||
type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups';
|
||||
@@ -82,6 +84,7 @@ type WorklistRow = {
|
||||
contactAttempts: number;
|
||||
source: string | null;
|
||||
lastDisposition: string | null;
|
||||
missedCallId: string | null;
|
||||
};
|
||||
|
||||
const priorityConfig: Record<string, { color: 'error' | 'warning' | 'brand' | 'gray'; label: string; sort: number }> = {
|
||||
@@ -164,6 +167,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
contactAttempts: 0,
|
||||
source: call.callsourcenumber ?? null,
|
||||
lastDisposition: call.disposition ?? null,
|
||||
missedCallId: call.id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -190,6 +194,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
contactAttempts: 0,
|
||||
source: null,
|
||||
lastDisposition: null,
|
||||
missedCallId: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -216,6 +221,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
contactAttempts: lead.contactAttempts ?? 0,
|
||||
source: lead.leadSource ?? lead.utmCampaign ?? null,
|
||||
lastDisposition: null,
|
||||
missedCallId: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -226,16 +232,17 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
const pa = priorityConfig[a.priority]?.sort ?? 2;
|
||||
const pb = priorityConfig[b.priority]?.sort ?? 2;
|
||||
if (pa !== pb) return pa - pb;
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
});
|
||||
|
||||
return actionableRows;
|
||||
};
|
||||
|
||||
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId }: WorklistPanelProps) => {
|
||||
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId, onDialMissedCall }: WorklistPanelProps) => {
|
||||
const [tab, setTab] = useState<TabKey>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [missedSubTab, setMissedSubTab] = useState<MissedSubTab>('pending');
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'sla', direction: 'descending' });
|
||||
|
||||
const missedByStatus = useMemo(() => ({
|
||||
pending: missedCalls.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus),
|
||||
@@ -268,8 +275,30 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
);
|
||||
}
|
||||
|
||||
if (sortDescriptor.column) {
|
||||
const dir = sortDescriptor.direction === 'ascending' ? 1 : -1;
|
||||
rows = [...rows].sort((a, b) => {
|
||||
switch (sortDescriptor.column) {
|
||||
case 'priority': {
|
||||
const pa = priorityConfig[a.priority]?.sort ?? 2;
|
||||
const pb = priorityConfig[b.priority]?.sort ?? 2;
|
||||
return (pa - pb) * dir;
|
||||
}
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name) * dir;
|
||||
case 'sla': {
|
||||
const ta = new Date(a.lastContactedAt ?? a.createdAt).getTime();
|
||||
const tb = new Date(b.lastContactedAt ?? b.createdAt).getTime();
|
||||
return (ta - tb) * dir;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [allRows, tab, search]);
|
||||
}, [allRows, tab, search, sortDescriptor, missedSubTabRows]);
|
||||
|
||||
const missedCount = allRows.filter((r) => r.type === 'missed').length;
|
||||
const leadCount = allRows.filter((r) => r.type === 'lead').length;
|
||||
@@ -373,13 +402,13 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-2 pt-3">
|
||||
<Table size="sm">
|
||||
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
||||
<Table.Header>
|
||||
<Table.Head label="PRIORITY" className="w-20" isRowHeader />
|
||||
<Table.Head label="PATIENT" />
|
||||
<Table.Head id="priority" label="PRIORITY" className="w-20" isRowHeader allowsSorting />
|
||||
<Table.Head id="name" label="PATIENT" allowsSorting />
|
||||
<Table.Head label="PHONE" />
|
||||
<Table.Head label={tab === 'missed' ? 'BRANCH' : 'SOURCE'} className="w-28" />
|
||||
<Table.Head label="SLA" className="w-24" />
|
||||
<Table.Head id="sla" label="SLA" className="w-24" allowsSorting />
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedRows}>
|
||||
{(row) => {
|
||||
@@ -432,6 +461,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
phoneNumber={row.phoneRaw}
|
||||
displayNumber={row.phone}
|
||||
leadId={row.leadId ?? undefined}
|
||||
onDial={row.missedCallId ? () => onDialMissedCall?.(row.missedCallId!) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-quaternary italic">No phone</span>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSip } from '@/providers/sip-provider';
|
||||
import { CallWidget } from '@/components/call-desk/call-widget';
|
||||
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
||||
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
|
||||
import { NotificationBell } from './notification-bell';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
||||
import { useNetworkStatus } from '@/hooks/use-network-status';
|
||||
@@ -19,7 +20,7 @@ interface AppShellProps {
|
||||
|
||||
export const AppShell = ({ children }: AppShellProps) => {
|
||||
const { pathname } = useLocation();
|
||||
const { isCCAgent } = useAuth();
|
||||
const { isCCAgent, isAdmin } = useAuth();
|
||||
const { isOpen, activeAction, close } = useMaintShortcuts();
|
||||
const { connectionStatus, isRegistered } = useSip();
|
||||
const networkQuality = useNetworkStatus();
|
||||
@@ -50,8 +51,11 @@ export const AppShell = ({ children }: AppShellProps) => {
|
||||
<Sidebar activeUrl={pathname} />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Persistent top bar — visible on all pages */}
|
||||
{hasAgentConfig && (
|
||||
{(hasAgentConfig || isAdmin) && (
|
||||
<div className="flex shrink-0 items-center justify-end gap-2 border-b border-secondary px-4 py-2">
|
||||
{isAdmin && <NotificationBell />}
|
||||
{hasAgentConfig && (
|
||||
<>
|
||||
<div className={cx(
|
||||
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
|
||||
networkQuality === 'good'
|
||||
@@ -67,6 +71,8 @@ export const AppShell = ({ children }: AppShellProps) => {
|
||||
{networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'}
|
||||
</div>
|
||||
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
|
||||
|
||||
142
src/components/layout/notification-bell.tsx
Normal file
142
src/components/layout/notification-bell.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faBell, faTriangleExclamation, faXmark, faCheck } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { usePerformanceAlerts, type PerformanceAlert } from '@/hooks/use-performance-alerts';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
const DEMO_ALERTS: PerformanceAlert[] = [
|
||||
{ id: 'demo-1', agent: 'Riya Mehta', type: 'Excessive Idle Time', value: '120m', severity: 'error', dismissed: false },
|
||||
{ id: 'demo-2', agent: 'Arjun Kapoor', type: 'Excessive Idle Time', value: '180m', severity: 'error', dismissed: false },
|
||||
{ id: 'demo-3', agent: 'Sneha Iyer', type: 'Excessive Idle Time', value: '250m', severity: 'error', dismissed: false },
|
||||
{ id: 'demo-4', agent: 'Vikrant Desai', type: 'Excessive Idle Time', value: '300m', severity: 'error', dismissed: false },
|
||||
{ id: 'demo-5', agent: 'Vikrant Desai', type: 'Low NPS', value: '35', severity: 'warning', dismissed: false },
|
||||
{ id: 'demo-6', agent: 'Vikrant Desai', type: 'Low Conversion', value: '40%', severity: 'warning', dismissed: false },
|
||||
{ id: 'demo-7', agent: 'Pooja Rao', type: 'Excessive Idle Time', value: '200m', severity: 'error', dismissed: false },
|
||||
{ id: 'demo-8', agent: 'Mohammed Rizwan', type: 'Excessive Idle Time', value: '80m', severity: 'error', dismissed: false },
|
||||
];
|
||||
|
||||
export const NotificationBell = () => {
|
||||
const { alerts: liveAlerts, dismiss: liveDismiss, dismissAll: liveDismissAll } = usePerformanceAlerts();
|
||||
const [demoAlerts, setDemoAlerts] = useState<PerformanceAlert[]>(DEMO_ALERTS);
|
||||
const [open, setOpen] = useState(true);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Use live alerts if available, otherwise demo
|
||||
const alerts = liveAlerts.length > 0 ? liveAlerts : demoAlerts.filter(a => !a.dismissed);
|
||||
const isDemo = liveAlerts.length === 0;
|
||||
|
||||
const dismiss = (id: string) => {
|
||||
if (isDemo) {
|
||||
setDemoAlerts(prev => prev.map(a => a.id === id ? { ...a, dismissed: true } : a));
|
||||
} else {
|
||||
liveDismiss(id);
|
||||
}
|
||||
};
|
||||
|
||||
const dismissAll = () => {
|
||||
if (isDemo) {
|
||||
setDemoAlerts(prev => prev.map(a => ({ ...a, dismissed: true })));
|
||||
} else {
|
||||
liveDismissAll();
|
||||
}
|
||||
};
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={panelRef}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cx(
|
||||
'relative flex size-9 items-center justify-center rounded-lg border transition duration-100 ease-linear',
|
||||
alerts.length > 0
|
||||
? 'border-error bg-error-primary text-fg-error-primary hover:bg-error-secondary'
|
||||
: open
|
||||
? 'border-brand bg-active text-brand-secondary'
|
||||
: 'border-secondary bg-primary text-fg-secondary hover:bg-primary_hover',
|
||||
)}
|
||||
title="Notifications"
|
||||
>
|
||||
<FontAwesomeIcon icon={faBell} className="size-5" />
|
||||
{alerts.length > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 flex size-5 items-center justify-center rounded-full bg-error-solid text-[10px] font-bold text-white ring-2 ring-white">
|
||||
{alerts.length > 9 ? '9+' : alerts.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full right-0 mt-2 w-96 rounded-xl bg-primary shadow-xl ring-1 ring-secondary z-50 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-primary">Notifications</span>
|
||||
{alerts.length > 0 && (
|
||||
<Badge size="sm" color="error" type="pill-color">{alerts.length}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{alerts.length > 0 && (
|
||||
<button
|
||||
onClick={dismissAll}
|
||||
className="text-xs font-medium text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alert list */}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{alerts.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<FontAwesomeIcon icon={faCheck} className="size-5 text-fg-success-primary mb-2" />
|
||||
<p className="text-xs text-tertiary">No active alerts</p>
|
||||
</div>
|
||||
) : (
|
||||
alerts.map(alert => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={cx(
|
||||
'flex items-center gap-3 px-4 py-3 border-b border-secondary last:border-b-0',
|
||||
alert.severity === 'error' ? 'bg-error-primary' : 'bg-warning-primary',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faTriangleExclamation}
|
||||
className={cx(
|
||||
'size-4 shrink-0',
|
||||
alert.severity === 'error' ? 'text-fg-error-primary' : 'text-fg-warning-primary',
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-primary">{alert.agent}</p>
|
||||
<p className="text-xs text-tertiary">{alert.type}</p>
|
||||
</div>
|
||||
<Badge size="sm" color={alert.severity} type="pill-color">{alert.value}</Badge>
|
||||
<button
|
||||
onClick={() => dismiss(alert.id)}
|
||||
className="text-fg-quaternary hover:text-fg-secondary shrink-0 transition duration-100 ease-linear"
|
||||
title="Dismiss"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
102
src/hooks/use-performance-alerts.ts
Normal file
102
src/hooks/use-performance-alerts.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { notify } from '@/lib/toast';
|
||||
|
||||
export type PerformanceAlert = {
|
||||
id: string;
|
||||
agent: string;
|
||||
type: 'Excessive Idle Time' | 'Low NPS' | 'Low Conversion';
|
||||
value: string;
|
||||
severity: 'error' | 'warning';
|
||||
dismissed: boolean;
|
||||
};
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||
|
||||
export const usePerformanceAlerts = () => {
|
||||
const { isAdmin } = useAuth();
|
||||
const { calls, leads } = useData();
|
||||
const [alerts, setAlerts] = useState<PerformanceAlert[]>([]);
|
||||
const [teamPerf, setTeamPerf] = useState<any>(null);
|
||||
const toastsFiredRef = useRef(false);
|
||||
|
||||
// Fetch team performance data from sidecar (same as team-performance page)
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const token = localStorage.getItem('helix_access_token') ?? '';
|
||||
fetch(`${API_URL}/api/supervisor/team-performance?date=${today}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => setTeamPerf(data))
|
||||
.catch(() => {});
|
||||
}, [isAdmin]);
|
||||
|
||||
// Compute alerts from team performance + entity data
|
||||
useMemo(() => {
|
||||
if (!isAdmin || !teamPerf?.agents) return;
|
||||
|
||||
const parseTime = (t: string): number => {
|
||||
const parts = t.split(':').map(Number);
|
||||
return (parts[0] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[2] ?? 0);
|
||||
};
|
||||
|
||||
const list: PerformanceAlert[] = [];
|
||||
let idx = 0;
|
||||
|
||||
for (const agent of teamPerf.agents) {
|
||||
const agentCalls = calls.filter(c => c.agentName === agent.name || c.agentName === agent.ozonetelagentid);
|
||||
const totalCalls = agentCalls.length;
|
||||
const agentAppts = agentCalls.filter((c: any) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||
const convPercent = totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0;
|
||||
|
||||
const tb = agent.timeBreakdown;
|
||||
const idleMinutes = tb ? Math.round(parseTime(tb.totalIdleTime ?? '0:0:0') / 60) : 0;
|
||||
|
||||
if (agent.maxidleminutes && idleMinutes > agent.maxidleminutes) {
|
||||
list.push({ id: `idle-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Excessive Idle Time', value: `${idleMinutes}m`, severity: 'error', dismissed: false });
|
||||
}
|
||||
if (agent.minnpsthreshold && (agent.npsscore ?? 100) < agent.minnpsthreshold) {
|
||||
list.push({ id: `nps-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Low NPS', value: String(agent.npsscore ?? 0), severity: 'warning', dismissed: false });
|
||||
}
|
||||
if (agent.minconversionpercent && convPercent < agent.minconversionpercent) {
|
||||
list.push({ id: `conv-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Low Conversion', value: `${convPercent}%`, severity: 'warning', dismissed: false });
|
||||
}
|
||||
}
|
||||
|
||||
setAlerts(list);
|
||||
}, [isAdmin, teamPerf, calls, leads]);
|
||||
|
||||
// Fire toasts once when alerts first load
|
||||
useEffect(() => {
|
||||
if (toastsFiredRef.current || alerts.length === 0) return;
|
||||
toastsFiredRef.current = true;
|
||||
|
||||
const idleCount = alerts.filter(a => a.type === 'Excessive Idle Time').length;
|
||||
const npsCount = alerts.filter(a => a.type === 'Low NPS').length;
|
||||
const convCount = alerts.filter(a => a.type === 'Low Conversion').length;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (idleCount > 0) parts.push(`${idleCount} excessive idle`);
|
||||
if (npsCount > 0) parts.push(`${npsCount} low NPS`);
|
||||
if (convCount > 0) parts.push(`${convCount} low conversion`);
|
||||
|
||||
if (parts.length > 0) {
|
||||
notify.error('Performance Alerts', `${alerts.length} alert(s): ${parts.join(', ')}`);
|
||||
}
|
||||
}, [alerts]);
|
||||
|
||||
const dismiss = (id: string) => {
|
||||
setAlerts(prev => prev.map(a => a.id === id ? { ...a, dismissed: true } : a));
|
||||
};
|
||||
|
||||
const dismissAll = () => {
|
||||
setAlerts(prev => prev.map(a => ({ ...a, dismissed: true })));
|
||||
};
|
||||
|
||||
const activeAlerts = alerts.filter(a => !a.dismissed);
|
||||
|
||||
return { alerts: activeAlerts, allAlerts: alerts, dismiss, dismissAll };
|
||||
};
|
||||
@@ -66,6 +66,14 @@ export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node {
|
||||
clinic { id name clinicName }
|
||||
} } } }`;
|
||||
|
||||
export const APPOINTMENTS_QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id name createdAt
|
||||
scheduledAt durationMin appointmentType status
|
||||
doctorName department reasonForVisit
|
||||
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
|
||||
doctor { id clinic { clinicName } }
|
||||
} } } }`;
|
||||
|
||||
export const PATIENTS_QUERY = `{ patients(first: 50) { edges { node {
|
||||
id name fullName { firstName lastName }
|
||||
phones { primaryPhoneNumber }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Transform platform GraphQL responses → frontend entity types
|
||||
// Platform remaps field names during sync — this layer normalizes them
|
||||
|
||||
import type { Lead, Campaign, Ad, FollowUp, LeadActivity, Call, Patient } from '@/types/entities';
|
||||
import type { Lead, Campaign, Ad, FollowUp, LeadActivity, Call, Patient, Appointment } from '@/types/entities';
|
||||
|
||||
type PlatformNode = Record<string, any>;
|
||||
|
||||
@@ -153,6 +153,25 @@ export function transformCalls(data: any): Call[] {
|
||||
}));
|
||||
}
|
||||
|
||||
export function transformAppointments(data: any): Appointment[] {
|
||||
return extractEdges(data, 'appointments').map((n) => ({
|
||||
id: n.id,
|
||||
createdAt: n.createdAt,
|
||||
scheduledAt: n.scheduledAt,
|
||||
durationMinutes: n.durationMin ?? 30,
|
||||
appointmentType: n.appointmentType,
|
||||
appointmentStatus: n.status,
|
||||
doctorName: n.doctorName,
|
||||
doctorId: n.doctor?.id ?? null,
|
||||
department: n.department,
|
||||
reasonForVisit: n.reasonForVisit,
|
||||
patientId: n.patient?.id ?? null,
|
||||
patientName: n.patient?.fullName ? `${n.patient.fullName.firstName} ${n.patient.fullName.lastName}`.trim() : null,
|
||||
patientPhone: n.patient?.phones?.primaryPhoneNumber ?? null,
|
||||
clinicName: n.doctor?.clinic?.clinicName ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function transformPatients(data: any): Patient[] {
|
||||
return extractEdges(data, 'patients').map((n) => ({
|
||||
id: n.id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
@@ -11,12 +11,13 @@ import { ContextPanel } from '@/components/call-desk/context-panel';
|
||||
import { ActiveCallCard } from '@/components/call-desk/active-call-card';
|
||||
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
export const CallDeskPage = () => {
|
||||
const { user } = useAuth();
|
||||
const { leadActivities } = useData();
|
||||
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
|
||||
const { callState, callerNumber, callUcid, dialOutbound } = useSip();
|
||||
const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist();
|
||||
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
||||
@@ -49,12 +50,53 @@ export const CallDeskPage = () => {
|
||||
|
||||
const isInCall = !callDismissed && (callState === 'ringing-in' || callState === 'ringing-out' || callState === 'active' || callState === 'ended' || callState === 'failed');
|
||||
|
||||
const callerLead = callerNumber
|
||||
// Resolve caller identity via sidecar (lookup-or-create lead+patient pair)
|
||||
const [resolvedCaller, setResolvedCaller] = useState<{
|
||||
leadId: string; patientId: string; firstName: string; lastName: string; phone: string;
|
||||
} | null>(null);
|
||||
const resolveAttemptedRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!callerNumber || !isInCall) return;
|
||||
if (resolveAttemptedRef.current === callerNumber) return; // already resolving/resolved this number
|
||||
resolveAttemptedRef.current = callerNumber;
|
||||
|
||||
apiClient.post<{
|
||||
leadId: string; patientId: string; firstName: string; lastName: string; phone: string; isNew: boolean;
|
||||
}>('/api/caller/resolve', { phone: callerNumber }, { silent: true })
|
||||
.then((result) => {
|
||||
setResolvedCaller(result);
|
||||
if (result.isNew) {
|
||||
notify.info('New Caller', 'Lead and patient records created');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[RESOLVE] Caller resolution failed:', err);
|
||||
resolveAttemptedRef.current = null; // allow retry
|
||||
});
|
||||
}, [callerNumber, isInCall]);
|
||||
|
||||
// Reset resolved caller when call ends
|
||||
useEffect(() => {
|
||||
if (!isInCall) {
|
||||
setResolvedCaller(null);
|
||||
resolveAttemptedRef.current = null;
|
||||
}
|
||||
}, [isInCall]);
|
||||
|
||||
// Build activeLead from resolved caller or fallback to client-side match
|
||||
const callerLead = resolvedCaller
|
||||
? marketingLeads.find((l) => l.id === resolvedCaller.leadId) ?? {
|
||||
id: resolvedCaller.leadId,
|
||||
contactName: { firstName: resolvedCaller.firstName, lastName: resolvedCaller.lastName },
|
||||
contactPhone: [{ number: resolvedCaller.phone, callingCode: '+91' }],
|
||||
patientId: resolvedCaller.patientId,
|
||||
}
|
||||
: callerNumber
|
||||
? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---'))
|
||||
: null;
|
||||
|
||||
// For inbound calls, only use matched lead (don't fall back to previously selected worklist lead)
|
||||
// For outbound (agent initiated from worklist), selectedLead is the intended target
|
||||
// For inbound calls, use resolved/matched lead. For outbound, use selectedLead.
|
||||
const activeLead = isInCall
|
||||
? (callerLead ?? (callState === 'ringing-out' ? selectedLead : null))
|
||||
: selectedLead;
|
||||
@@ -147,7 +189,7 @@ export const CallDeskPage = () => {
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Main panel */}
|
||||
<div className="flex flex-1 flex-col min-h-0">
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
{/* Active call */}
|
||||
{isInCall && (
|
||||
<div className="p-5">
|
||||
@@ -164,6 +206,7 @@ export const CallDeskPage = () => {
|
||||
loading={loading}
|
||||
onSelectLead={(lead) => setSelectedLead(lead)}
|
||||
selectedLeadId={selectedLead?.id ?? null}
|
||||
onDialMissedCall={(id) => setActiveMissedCallId(id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -177,6 +220,10 @@ export const CallDeskPage = () => {
|
||||
<ContextPanel
|
||||
selectedLead={activeLeadFull}
|
||||
activities={leadActivities}
|
||||
calls={calls}
|
||||
followUps={dataFollowUps}
|
||||
appointments={appointments}
|
||||
patients={patients}
|
||||
callerPhone={callerNumber ?? undefined}
|
||||
isInCall={isInCall}
|
||||
callUcid={callUcid}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FOLLOW_UPS_QUERY,
|
||||
LEAD_ACTIVITIES_QUERY,
|
||||
CALLS_QUERY,
|
||||
APPOINTMENTS_QUERY,
|
||||
PATIENTS_QUERY,
|
||||
} from '@/lib/queries';
|
||||
import {
|
||||
@@ -17,10 +18,11 @@ import {
|
||||
transformFollowUps,
|
||||
transformLeadActivities,
|
||||
transformCalls,
|
||||
transformAppointments,
|
||||
transformPatients,
|
||||
} from '@/lib/transforms';
|
||||
|
||||
import type { Lead, Campaign, Ad, LeadActivity, FollowUp, WhatsAppTemplate, Agent, Call, LeadIngestionSource, Patient } from '@/types/entities';
|
||||
import type { Lead, Campaign, Ad, LeadActivity, FollowUp, WhatsAppTemplate, Agent, Call, LeadIngestionSource, Patient, Appointment } from '@/types/entities';
|
||||
|
||||
type DataContextType = {
|
||||
leads: Lead[];
|
||||
@@ -31,6 +33,7 @@ type DataContextType = {
|
||||
templates: WhatsAppTemplate[];
|
||||
agents: Agent[];
|
||||
calls: Call[];
|
||||
appointments: Appointment[];
|
||||
patients: Patient[];
|
||||
ingestionSources: LeadIngestionSource[];
|
||||
loading: boolean;
|
||||
@@ -63,6 +66,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
||||
const [followUps, setFollowUps] = useState<FollowUp[]>([]);
|
||||
const [leadActivities, setLeadActivities] = useState<LeadActivity[]>([]);
|
||||
const [calls, setCalls] = useState<Call[]>([]);
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -84,13 +88,14 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
||||
try {
|
||||
const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null);
|
||||
|
||||
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, patientsData] = await Promise.all([
|
||||
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, appointmentsData, patientsData] = await Promise.all([
|
||||
gql<any>(LEADS_QUERY),
|
||||
gql<any>(CAMPAIGNS_QUERY),
|
||||
gql<any>(ADS_QUERY),
|
||||
gql<any>(FOLLOW_UPS_QUERY),
|
||||
gql<any>(LEAD_ACTIVITIES_QUERY),
|
||||
gql<any>(CALLS_QUERY),
|
||||
gql<any>(APPOINTMENTS_QUERY),
|
||||
gql<any>(PATIENTS_QUERY),
|
||||
]);
|
||||
|
||||
@@ -100,6 +105,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
||||
if (followUpsData) setFollowUps(transformFollowUps(followUpsData));
|
||||
if (activitiesData) setLeadActivities(transformLeadActivities(activitiesData));
|
||||
if (callsData) setCalls(transformCalls(callsData));
|
||||
if (appointmentsData) setAppointments(transformAppointments(appointmentsData));
|
||||
if (patientsData) setPatients(transformPatients(patientsData));
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to load data');
|
||||
@@ -122,7 +128,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
||||
|
||||
return (
|
||||
<DataContext.Provider value={{
|
||||
leads, campaigns, ads, followUps, leadActivities, templates, agents, calls, patients, ingestionSources,
|
||||
leads, campaigns, ads, followUps, leadActivities, templates, agents, calls, appointments, patients, ingestionSources,
|
||||
loading, error,
|
||||
updateLead, addCall, refresh: fetchData,
|
||||
}}>
|
||||
|
||||
@@ -294,6 +294,27 @@ export type Patient = {
|
||||
patientType: PatientType | null;
|
||||
};
|
||||
|
||||
// Appointment domain
|
||||
export type AppointmentStatus = 'SCHEDULED' | 'CONFIRMED' | 'CHECKED_IN' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW';
|
||||
export type AppointmentType = 'CONSULTATION' | 'FOLLOW_UP' | 'PROCEDURE' | 'EMERGENCY';
|
||||
|
||||
export type Appointment = {
|
||||
id: string;
|
||||
createdAt: string | null;
|
||||
scheduledAt: string | null;
|
||||
durationMinutes: number | null;
|
||||
appointmentType: AppointmentType | null;
|
||||
appointmentStatus: AppointmentStatus | null;
|
||||
doctorName: string | null;
|
||||
doctorId: string | null;
|
||||
department: string | null;
|
||||
reasonForVisit: string | null;
|
||||
patientId: string | null;
|
||||
patientName: string | null;
|
||||
patientPhone: string | null;
|
||||
clinicName: string | null;
|
||||
};
|
||||
|
||||
// Lead Ingestion Source domain
|
||||
export type IntegrationStatus = 'ACTIVE' | 'WARNING' | 'ERROR' | 'DISABLED';
|
||||
export type AuthStatus = 'VALID' | 'EXPIRING_SOON' | 'EXPIRED' | 'NOT_CONFIGURED';
|
||||
|
||||
Reference in New Issue
Block a user