mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-12 02:38: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:
@@ -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,107 +203,115 @@ 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 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">
|
||||
<FontAwesomeIcon icon={faPhone} className="size-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-primary">{fullName || phoneDisplay}</p>
|
||||
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
|
||||
<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">
|
||||
<FontAwesomeIcon icon={faPhone} className="size-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-primary">{fullName || phoneDisplay}</p>
|
||||
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
|
||||
</div>
|
||||
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
|
||||
|
||||
{/* Call controls */}
|
||||
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
title={isMuted ? 'Unmute' : 'Mute'}
|
||||
className={cx(
|
||||
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
|
||||
isMuted ? 'bg-error-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleHold}
|
||||
title={isOnHold ? 'Resume' : 'Hold'}
|
||||
className={cx(
|
||||
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
|
||||
isOnHold ? 'bg-warning-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const action = recordingPaused ? 'unPause' : 'pause';
|
||||
if (callUcid) apiClient.post('/api/ozonetel/recording', { ucid: callUcid, action }).catch(() => {});
|
||||
setRecordingPaused(!recordingPaused);
|
||||
}}
|
||||
title={recordingPaused ? 'Resume Recording' : 'Pause Recording'}
|
||||
className={cx(
|
||||
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
|
||||
recordingPaused ? 'bg-error-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRecordVinyl} className="size-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-secondary mx-0.5" />
|
||||
|
||||
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
||||
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); }}>Book Appt</Button>
|
||||
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
||||
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); }}>Enquiry</Button>
|
||||
<Button size="sm" color="secondary"
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
||||
onClick={() => setTransferOpen(!transferOpen)}>Transfer</Button>
|
||||
|
||||
<Button size="sm" color="primary-destructive" className="ml-auto"
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneHangup} className={className} {...rest} />}
|
||||
onClick={() => setDispositionOpen(true)}>End Call</Button>
|
||||
</div>
|
||||
|
||||
{/* Transfer dialog */}
|
||||
{transferOpen && callUcid && (
|
||||
<TransferDialog
|
||||
ucid={callUcid}
|
||||
onClose={() => setTransferOpen(false)}
|
||||
onTransferred={() => {
|
||||
setTransferOpen(false);
|
||||
setSuggestedDisposition('FOLLOW_UP_SCHEDULED');
|
||||
setDispositionOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Call controls */}
|
||||
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
title={isMuted ? 'Unmute' : 'Mute'}
|
||||
className={cx(
|
||||
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
|
||||
isMuted ? 'bg-error-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleHold}
|
||||
title={isOnHold ? 'Resume' : 'Hold'}
|
||||
className={cx(
|
||||
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
|
||||
isOnHold ? 'bg-warning-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const action = recordingPaused ? 'unPause' : 'pause';
|
||||
if (callUcid) apiClient.post('/api/ozonetel/recording', { ucid: callUcid, action }).catch(() => {});
|
||||
setRecordingPaused(!recordingPaused);
|
||||
}}
|
||||
title={recordingPaused ? 'Resume Recording' : 'Pause Recording'}
|
||||
className={cx(
|
||||
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
|
||||
recordingPaused ? 'bg-error-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRecordVinyl} className="size-3.5" />
|
||||
</button>
|
||||
{/* 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}
|
||||
callerNumber={callerPhone}
|
||||
leadName={fullName || null}
|
||||
leadId={lead?.id ?? null}
|
||||
patientId={(lead as any)?.patientId ?? null}
|
||||
onSaved={handleAppointmentSaved}
|
||||
/>
|
||||
|
||||
<div className="w-px h-6 bg-secondary mx-0.5" />
|
||||
|
||||
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
||||
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); }}>Book Appt</Button>
|
||||
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
||||
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); }}>Enquiry</Button>
|
||||
<Button size="sm" color="secondary"
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
||||
onClick={() => setTransferOpen(!transferOpen)}>Transfer</Button>
|
||||
|
||||
<Button size="sm" color="primary-destructive" className="ml-auto"
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneHangup} className={className} {...rest} />}
|
||||
onClick={() => setDispositionOpen(true)}>End Call</Button>
|
||||
</div>
|
||||
|
||||
{/* Transfer dialog */}
|
||||
{transferOpen && callUcid && (
|
||||
<TransferDialog
|
||||
ucid={callUcid}
|
||||
onClose={() => setTransferOpen(false)}
|
||||
onTransferred={() => {
|
||||
setTransferOpen(false);
|
||||
setDispositionOpen(true);
|
||||
}}
|
||||
/>
|
||||
<EnquiryForm
|
||||
isOpen={enquiryOpen}
|
||||
onOpenChange={setEnquiryOpen}
|
||||
callerPhone={callerPhone}
|
||||
onSaved={() => {
|
||||
setEnquiryOpen(false);
|
||||
setSuggestedDisposition('INFO_PROVIDED');
|
||||
notify.success('Enquiry Logged');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Appointment form */}
|
||||
<AppointmentForm
|
||||
isOpen={appointmentOpen}
|
||||
onOpenChange={setAppointmentOpen}
|
||||
callerNumber={callerPhone}
|
||||
leadName={fullName || null}
|
||||
leadId={lead?.id ?? null}
|
||||
patientId={(lead as any)?.patientId ?? null}
|
||||
onSaved={handleAppointmentSaved}
|
||||
/>
|
||||
|
||||
{/* Enquiry form */}
|
||||
<EnquiryForm
|
||||
isOpen={enquiryOpen}
|
||||
onOpenChange={setEnquiryOpen}
|
||||
callerPhone={callerPhone}
|
||||
onSaved={() => {
|
||||
setEnquiryOpen(false);
|
||||
notify.success('Enquiry Logged');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Disposition Modal — the ONLY path to end a call */}
|
||||
@@ -309,6 +319,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
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 */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Lead profile */}
|
||||
<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>
|
||||
<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" />
|
||||
)}
|
||||
{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>
|
||||
<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.interestedService && (
|
||||
<p className="text-xs text-secondary">Interested in: {lead.interestedService}</p>
|
||||
)}
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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>}
|
||||
{lead.aiSuggestedAction && (
|
||||
<p className="mt-1 text-xs font-semibold text-brand-secondary">{lead.aiSuggestedAction}</p>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Upcoming appointments */}
|
||||
{appointments.length > 0 && (
|
||||
<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" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Chat — always available at the bottom */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<AiChatPanel callerContext={callerContext} />
|
||||
{/* 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,20 +73,44 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Create a lead with source PHONE_INQUIRY
|
||||
await apiClient.graphql(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: `Enquiry — ${patientName}`,
|
||||
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
|
||||
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
|
||||
source: 'PHONE',
|
||||
status: disposition === 'CONVERTED' ? 'CONVERTED' : 'NEW',
|
||||
interestedService: queryAsked.substring(0, 100),
|
||||
// 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 } }`,
|
||||
{
|
||||
data: {
|
||||
name: `Enquiry — ${patientName}`,
|
||||
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
|
||||
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
|
||||
source: 'PHONE',
|
||||
status: disposition === 'CONVERTED' ? 'CONVERTED' : 'NEW',
|
||||
interestedService: queryAsked.substring(0, 100),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 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,23 +51,28 @@ 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">
|
||||
<div className={cx(
|
||||
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
|
||||
networkQuality === 'good'
|
||||
? 'bg-success-primary text-success-primary'
|
||||
: networkQuality === 'offline'
|
||||
? 'bg-error-secondary text-error-primary'
|
||||
: 'bg-warning-secondary text-warning-primary',
|
||||
)}>
|
||||
<FontAwesomeIcon
|
||||
icon={networkQuality === 'offline' ? faWifiSlash : faWifi}
|
||||
className="size-3"
|
||||
/>
|
||||
{networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'}
|
||||
</div>
|
||||
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
|
||||
{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'
|
||||
? 'bg-success-primary text-success-primary'
|
||||
: networkQuality === 'offline'
|
||||
? 'bg-error-secondary text-error-primary'
|
||||
: 'bg-warning-secondary text-warning-primary',
|
||||
)}>
|
||||
<FontAwesomeIcon
|
||||
icon={networkQuality === 'offline' ? faWifiSlash : faWifi}
|
||||
className="size-3"
|
||||
/>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user