mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
- 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>
326 lines
19 KiB
TypeScript
326 lines
19 KiB
TypeScript
import { useState, useCallback, useMemo } from 'react';
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
import {
|
|
faSparkles, faPhone, faChevronDown, faChevronUp,
|
|
faCalendarCheck, faClockRotateLeft, faPhoneMissed,
|
|
faPhoneArrowDown, faPhoneArrowUp, faListCheck,
|
|
} from '@fortawesome/pro-duotone-svg-icons';
|
|
import { AiChatPanel } from './ai-chat-panel';
|
|
import { Badge } from '@/components/base/badges/badges';
|
|
import { formatPhone, formatShortDate } from '@/lib/format';
|
|
import { cx } from '@/utils/cx';
|
|
import type { 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;
|
|
}
|
|
|
|
const formatTimeAgo = (dateStr: string): string => {
|
|
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
|
|
if (minutes < 1) return 'Just now';
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
return `${Math.floor(hours / 24)}d ago`;
|
|
};
|
|
|
|
const formatDuration = (sec: number): string => {
|
|
if (sec < 60) return `${sec}s`;
|
|
return `${Math.floor(sec / 60)}m ${sec % 60}s`;
|
|
};
|
|
|
|
const SectionHeader = ({ icon, label, count, expanded, onToggle }: {
|
|
icon: any; label: string; count?: number; expanded: boolean; onToggle: () => void;
|
|
}) => (
|
|
<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 callerContext = lead ? {
|
|
callerPhone: phone?.number ?? callerPhone,
|
|
leadId: lead.id,
|
|
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">
|
|
{/* Lead header — always visible */}
|
|
{lead && (
|
|
<div className="shrink-0 border-b border-secondary">
|
|
<button
|
|
onClick={() => setContextExpanded(!contextExpanded)}
|
|
className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-primary_hover transition duration-100 ease-linear"
|
|
>
|
|
{isInCall && (
|
|
<FontAwesomeIcon icon={faPhone} className="size-3 text-fg-success-primary shrink-0" />
|
|
)}
|
|
<span className="text-sm font-semibold text-primary truncate">{fullName || 'Unknown'}</span>
|
|
{phone && (
|
|
<span className="text-xs text-tertiary shrink-0">{formatPhone(phone)}</span>
|
|
)}
|
|
{lead.leadStatus && (
|
|
<Badge size="sm" color="brand" type="pill-color" className="shrink-0">{lead.leadStatus.replace(/_/g, ' ')}</Badge>
|
|
)}
|
|
<FontAwesomeIcon
|
|
icon={contextExpanded ? faChevronUp : faChevronDown}
|
|
className="size-3 text-fg-quaternary ml-auto shrink-0"
|
|
/>
|
|
</button>
|
|
|
|
{/* Expanded context sections */}
|
|
{contextExpanded && (
|
|
<div className="px-4 pb-3 space-y-1 overflow-y-auto" style={{ maxHeight: '50vh' }}>
|
|
{/* AI Insight */}
|
|
{lead.aiSummary && (
|
|
<div>
|
|
<SectionHeader icon={faSparkles} label="AI Insight" expanded={insightExpanded} onToggle={() => setInsightExpanded(!insightExpanded)} />
|
|
{insightExpanded && (
|
|
<div className="rounded-lg bg-brand-primary p-2.5 mb-1">
|
|
<p className="text-xs leading-relaxed text-primary">{lead.aiSummary}</p>
|
|
{lead.aiSuggestedAction && (
|
|
<p className="mt-1.5 text-xs font-semibold text-brand-secondary">{lead.aiSuggestedAction}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Quick Actions — upcoming appointments + follow-ups + linked patient */}
|
|
{(leadAppointments.length > 0 || leadFollowUps.length > 0 || linkedPatient) && (
|
|
<div>
|
|
<SectionHeader icon={faListCheck} label="Upcoming" count={leadAppointments.length + leadFollowUps.length} expanded={actionsExpanded} onToggle={() => setActionsExpanded(!actionsExpanded)} />
|
|
{actionsExpanded && (
|
|
<div className="space-y-1 mb-1">
|
|
{leadAppointments.map(appt => (
|
|
<div key={appt.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
|
|
<FontAwesomeIcon icon={faCalendarCheck} className="size-3 text-fg-brand-primary shrink-0" />
|
|
<div className="min-w-0 flex-1">
|
|
<span className="text-xs font-medium text-primary">
|
|
{appt.doctorName ?? 'Appointment'}
|
|
</span>
|
|
<span className="text-[11px] text-tertiary ml-1">
|
|
{appt.department}
|
|
</span>
|
|
{appt.scheduledAt && (
|
|
<span className="text-[11px] text-tertiary ml-1">
|
|
— {formatShortDate(appt.scheduledAt)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Badge size="sm" color={appt.appointmentStatus === 'COMPLETED' ? 'success' : 'brand'} type="pill-color">
|
|
{appt.appointmentStatus?.replace(/_/g, ' ') ?? 'Scheduled'}
|
|
</Badge>
|
|
<button
|
|
onClick={() => setEditingAppointment(appt)}
|
|
className="text-[11px] font-medium text-brand-secondary hover:text-brand-secondary_hover shrink-0"
|
|
>
|
|
Edit
|
|
</button>
|
|
</div>
|
|
))}
|
|
{leadFollowUps.map(fu => (
|
|
<div key={fu.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
|
|
<FontAwesomeIcon icon={faClockRotateLeft} className="size-3 text-fg-warning-primary shrink-0" />
|
|
<div className="min-w-0 flex-1">
|
|
<span className="text-xs font-medium text-primary">
|
|
{fu.followUpType?.replace(/_/g, ' ') ?? 'Follow-up'}
|
|
</span>
|
|
{fu.scheduledAt && (
|
|
<span className="text-[11px] text-tertiary ml-1.5">
|
|
{formatShortDate(fu.scheduledAt)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Badge size="sm" color={fu.followUpStatus === 'OVERDUE' ? 'error' : 'gray'} type="pill-color">
|
|
{fu.followUpStatus?.replace(/_/g, ' ') ?? 'Pending'}
|
|
</Badge>
|
|
</div>
|
|
))}
|
|
{linkedPatient && (
|
|
<div className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
|
|
<FontAwesomeIcon icon={faPhone} className="size-3 text-fg-success-primary shrink-0" />
|
|
<span className="text-xs text-primary">
|
|
Patient: <span className="font-medium">{linkedPatient.fullName?.firstName} {linkedPatient.fullName?.lastName}</span>
|
|
</span>
|
|
{linkedPatient.patientType && (
|
|
<Badge size="sm" color="gray" type="pill-color" className="ml-auto">{linkedPatient.patientType}</Badge>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent calls + activities */}
|
|
{(leadCalls.length > 0 || leadActivities.length > 0) && (
|
|
<div>
|
|
<SectionHeader
|
|
icon={faClockRotateLeft}
|
|
label="Recent"
|
|
count={leadCalls.length + leadActivities.length}
|
|
expanded={recentExpanded}
|
|
onToggle={() => setRecentExpanded(!recentExpanded)}
|
|
/>
|
|
{recentExpanded && (
|
|
<div className="space-y-0.5 mb-1">
|
|
{leadCalls.map(call => (
|
|
<div key={call.id} className="flex items-center gap-2 py-1.5 px-1">
|
|
<FontAwesomeIcon
|
|
icon={call.callStatus === 'MISSED' ? faPhoneMissed : call.callDirection === 'INBOUND' ? faPhoneArrowDown : faPhoneArrowUp}
|
|
className={cx('size-3 shrink-0',
|
|
call.callStatus === 'MISSED' ? 'text-fg-error-primary' :
|
|
call.callDirection === 'INBOUND' ? 'text-fg-success-secondary' : 'text-fg-brand-secondary'
|
|
)}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<span className="text-xs text-primary">
|
|
{call.callStatus === 'MISSED' ? 'Missed' : call.callDirection === 'INBOUND' ? 'Inbound' : 'Outbound'} call
|
|
</span>
|
|
{call.durationSeconds != null && call.durationSeconds > 0 && (
|
|
<span className="text-[11px] text-tertiary ml-1">— {formatDuration(call.durationSeconds)}</span>
|
|
)}
|
|
{call.disposition && (
|
|
<span className="text-[11px] text-tertiary ml-1">, {call.disposition.replace(/_/g, ' ')}</span>
|
|
)}
|
|
</div>
|
|
<span className="text-[11px] text-quaternary shrink-0">
|
|
{formatTimeAgo(call.startedAt ?? call.createdAt)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
{leadActivities
|
|
.filter(a => !leadCalls.some(c => a.summary?.includes(c.callerNumber?.[0]?.number ?? '---')))
|
|
.slice(0, 3)
|
|
.map(a => (
|
|
<div key={a.id} className="flex items-center gap-2 py-1.5 px-1">
|
|
<span className="size-1.5 rounded-full bg-fg-quaternary shrink-0" />
|
|
<span className="text-xs text-tertiary truncate flex-1">{a.summary}</span>
|
|
{a.occurredAt && (
|
|
<span className="text-[11px] text-quaternary shrink-0">{formatTimeAgo(a.occurredAt)}</span>
|
|
)}
|
|
</div>
|
|
))
|
|
}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* No context available */}
|
|
{!hasContext && (
|
|
<p className="text-xs text-quaternary py-2">No history for this lead yet.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* AI Chat — fills remaining space */}
|
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
|
<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>
|
|
);
|
|
};
|