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:
2026-03-30 14:45:52 +05:30
parent 0477064b3e
commit c3c3f4b3d7
18 changed files with 882 additions and 389 deletions

View File

@@ -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[] = [];