mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
fix: Book Appt pills + AI chat clears on call end
Book Appt defect (QA-559): no visible path to edit an existing appointment — the Upcoming section in the context panel collapses automatically when the AI auto-summary fires, hiding the Edit action. Fix: render appointment pills above the AppointmentForm drawer when the returning patient has upcoming appointments: [+ New] [Apr 24 · Dr. Harpreet Edit] [May 02 · Dr. Meena Edit] - Click [+ New] (default): empty form, create mode - Click Edit on a pill: form prefills with that appointment, edit mode - Closing the drawer resets the selected pill Separate defect: AI chat persisted after call ended — stale summary from the previous call stayed visible on the worklist. ai-chat-panel now wipes messages + resets the auto-fire guard when callerContext.leadId transitions to null (call dropped/released, no selected lead). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import {
|
||||||
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
|
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
|
||||||
faPause, faPlay, faCalendarPlus,
|
faPause, faPlay, faCalendarPlus, faPlus, faPenToSquare,
|
||||||
faPhoneArrowRight, faRecordVinyl, faClipboardQuestion,
|
faPhoneArrowRight, faRecordVinyl, faClipboardQuestion,
|
||||||
} from '@fortawesome/pro-duotone-svg-icons';
|
} from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { useData } from '@/providers/data-provider';
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
@@ -16,7 +17,7 @@ import type { CallAction } from './disposition-modal';
|
|||||||
import { AppointmentForm } from './appointment-form';
|
import { AppointmentForm } from './appointment-form';
|
||||||
import { TransferDialog } from './transfer-dialog';
|
import { TransferDialog } from './transfer-dialog';
|
||||||
import { EnquiryForm } from './enquiry-form';
|
import { EnquiryForm } from './enquiry-form';
|
||||||
import { formatPhone } from '@/lib/format';
|
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useAuth } from '@/providers/auth-provider';
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
import { useAgentState } from '@/hooks/use-agent-state';
|
import { useAgentState } from '@/hooks/use-agent-state';
|
||||||
@@ -44,6 +45,10 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
||||||
const [appointmentOpen, setAppointmentOpen] = useState(false);
|
const [appointmentOpen, setAppointmentOpen] = useState(false);
|
||||||
|
// Which existing appointment is being edited (null = creating a new one).
|
||||||
|
// The Book Appt drawer shows pills: [+ New] + one per upcoming appointment.
|
||||||
|
// Clicking Edit on a pill sets this; clicking + New clears it.
|
||||||
|
const [editingApptId, setEditingApptId] = useState<string | null>(null);
|
||||||
const [transferOpen, setTransferOpen] = useState(false);
|
const [transferOpen, setTransferOpen] = useState(false);
|
||||||
const [recordingPaused, setRecordingPaused] = useState(false);
|
const [recordingPaused, setRecordingPaused] = useState(false);
|
||||||
const [enquiryOpen, setEnquiryOpen] = useState(false);
|
const [enquiryOpen, setEnquiryOpen] = useState(false);
|
||||||
@@ -62,6 +67,28 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Upcoming appointments for this caller (if returning patient) — drives
|
||||||
|
// the pill row above AppointmentForm so the agent can edit existing
|
||||||
|
// bookings in addition to creating new ones.
|
||||||
|
const { appointments } = useData();
|
||||||
|
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'
|
||||||
|
&& a.appointmentStatus !== 'COMPLETED',
|
||||||
|
)
|
||||||
|
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime());
|
||||||
|
}, [appointments, lead]);
|
||||||
|
|
||||||
|
const editingAppt = useMemo(
|
||||||
|
() => (editingApptId ? leadAppointments.find((a) => a.id === editingApptId) ?? null : null),
|
||||||
|
[leadAppointments, editingApptId],
|
||||||
|
);
|
||||||
|
|
||||||
const agentConfig = localStorage.getItem('helix_agent_config');
|
const agentConfig = localStorage.getItem('helix_agent_config');
|
||||||
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
||||||
const { supervisorPresence } = useAgentState(agentIdForState);
|
const { supervisorPresence } = useAgentState(agentIdForState);
|
||||||
@@ -335,14 +362,76 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{appointmentOpen && leadAppointments.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingApptId(null)}
|
||||||
|
className={cx(
|
||||||
|
'flex items-center gap-1.5 rounded-lg border-2 px-3 py-2 text-xs font-semibold transition duration-100 ease-linear',
|
||||||
|
!editingApptId
|
||||||
|
? 'border-brand bg-brand-primary text-brand-secondary'
|
||||||
|
: 'border-secondary bg-primary text-secondary hover:bg-primary_hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPlus} className="size-3" />
|
||||||
|
New
|
||||||
|
</button>
|
||||||
|
{leadAppointments.map((appt) => (
|
||||||
|
<div
|
||||||
|
key={appt.id}
|
||||||
|
className={cx(
|
||||||
|
'flex items-center gap-2 rounded-lg border-2 px-3 py-2 text-xs',
|
||||||
|
editingApptId === appt.id
|
||||||
|
? 'border-brand bg-brand-primary'
|
||||||
|
: 'border-secondary bg-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-semibold text-primary">
|
||||||
|
{appt.scheduledAt ? formatShortDate(appt.scheduledAt) : 'No date'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-tertiary">
|
||||||
|
{appt.doctorName ?? 'Doctor'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingApptId(appt.id)}
|
||||||
|
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-semibold text-brand-secondary hover:bg-brand-primary_hover transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPenToSquare} className="size-3" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<AppointmentForm
|
<AppointmentForm
|
||||||
isOpen={appointmentOpen}
|
isOpen={appointmentOpen}
|
||||||
onOpenChange={setAppointmentOpen}
|
onOpenChange={(open) => {
|
||||||
|
setAppointmentOpen(open);
|
||||||
|
if (!open) setEditingApptId(null);
|
||||||
|
}}
|
||||||
callerNumber={callerPhone}
|
callerNumber={callerPhone}
|
||||||
leadName={fullName || null}
|
leadName={fullName || null}
|
||||||
leadId={lead?.id ?? null}
|
leadId={lead?.id ?? null}
|
||||||
patientId={(lead as any)?.patientId ?? null}
|
patientId={(lead as any)?.patientId ?? null}
|
||||||
onSaved={handleAppointmentSaved}
|
existingAppointment={editingAppt ? {
|
||||||
|
id: editingAppt.id,
|
||||||
|
scheduledAt: editingAppt.scheduledAt ?? '',
|
||||||
|
doctorName: editingAppt.doctorName ?? '',
|
||||||
|
doctorId: editingAppt.doctorId ?? undefined,
|
||||||
|
department: editingAppt.department ?? '',
|
||||||
|
clinicId: editingAppt.clinicId ?? undefined,
|
||||||
|
reasonForVisit: editingAppt.reasonForVisit ?? undefined,
|
||||||
|
status: editingAppt.appointmentStatus ?? 'SCHEDULED',
|
||||||
|
} : null}
|
||||||
|
onSaved={(outcome) => {
|
||||||
|
setEditingApptId(null);
|
||||||
|
handleAppointmentSaved(outcome);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EnquiryForm
|
<EnquiryForm
|
||||||
|
|||||||
@@ -50,14 +50,26 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
|||||||
}, [messages, onChatStart]);
|
}, [messages, onChatStart]);
|
||||||
|
|
||||||
// Auto-fire a patient-summary request when a caller with a leadId appears
|
// Auto-fire a patient-summary request when a caller with a leadId appears
|
||||||
// on the panel. Resets whenever the caller changes (new incoming call) so
|
// on the panel. Resets whenever the caller changes (new incoming call) or
|
||||||
// each call starts fresh. The sidecar's AI agent inspects the leadId and
|
// the call ends (leadId clears), so each call starts fresh. The sidecar's
|
||||||
// replies with appointment/disposition/notes history when the caller is
|
// AI agent inspects the leadId and replies with appointment/disposition/
|
||||||
// a returning patient, or a brief "net-new caller" ack otherwise.
|
// notes history when the caller is a returning patient.
|
||||||
const autoFiredForLeadRef = useRef<string | null>(null);
|
const autoFiredForLeadRef = useRef<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const leadId = callerContext?.leadId ?? null;
|
const leadId = callerContext?.leadId ?? null;
|
||||||
if (!leadId) return;
|
|
||||||
|
// Call ended or no caller — wipe the panel so the next caller's
|
||||||
|
// context doesn't bleed over and the agent isn't staring at a stale
|
||||||
|
// summary in the worklist view between calls.
|
||||||
|
if (!leadId) {
|
||||||
|
if (autoFiredForLeadRef.current !== null) {
|
||||||
|
autoFiredForLeadRef.current = null;
|
||||||
|
setMessages([]);
|
||||||
|
chatStartedRef.current = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (autoFiredForLeadRef.current === leadId) return;
|
if (autoFiredForLeadRef.current === leadId) return;
|
||||||
|
|
||||||
// New caller — clear any prior chat state and fire the summary prompt.
|
// New caller — clear any prior chat state and fire the summary prompt.
|
||||||
|
|||||||
Reference in New Issue
Block a user