mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +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:
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user