mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
fix: P1 call-desk defects batch
- Mute persists across calls: sip-manager's "ended/failed" branch now resets the Recoil sipIsMutedAtom + sipIsOnHoldAtom (previously only the SIP track was unmuted, leaving the UI icon + toggle logic in a muted state that the next call inherited). - Telephony-unavailable dial pad: call-desk.tsx dial-pad "Call" button was missing an isRegistered check in its disabled prop, so it stayed clickable when SIP was down. Button now shows "Telephony unavailable" and is disabled. - Past dates in Follow-up: enquiry-form's follow-up date input had no min constraint. Switched to a raw <input type="date"> with min set to today's ISO date. - Returning-patient AI summary during call: ai-chat-panel now auto-fires a "give me a quick summary of <caller>" request whenever the caller's leadId changes (new incoming call). Clears prior chat state so each caller starts fresh. - Remove Type column in Patients page (Badge import also pruned). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,7 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
|||||||
|
|
||||||
const token = localStorage.getItem('helix_access_token') ?? '';
|
const token = localStorage.getItem('helix_access_token') ?? '';
|
||||||
|
|
||||||
const { messages, input, handleSubmit, handleInputChange, isLoading, append } = useChat({
|
const { messages, input, handleSubmit, handleInputChange, isLoading, append, setMessages } = useChat({
|
||||||
api: `${API_URL}/api/ai/stream`,
|
api: `${API_URL}/api/ai/stream`,
|
||||||
streamProtocol: 'text',
|
streamProtocol: 'text',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -49,6 +49,28 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
|||||||
}
|
}
|
||||||
}, [messages, onChatStart]);
|
}, [messages, onChatStart]);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// each call starts fresh. The sidecar's AI agent inspects the leadId and
|
||||||
|
// replies with appointment/disposition/notes history when the caller is
|
||||||
|
// a returning patient, or a brief "net-new caller" ack otherwise.
|
||||||
|
const autoFiredForLeadRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const leadId = callerContext?.leadId ?? null;
|
||||||
|
if (!leadId) return;
|
||||||
|
if (autoFiredForLeadRef.current === leadId) return;
|
||||||
|
|
||||||
|
// New caller — clear any prior chat state and fire the summary prompt.
|
||||||
|
autoFiredForLeadRef.current = leadId;
|
||||||
|
setMessages([]);
|
||||||
|
chatStartedRef.current = false;
|
||||||
|
const name = callerContext?.leadName ?? 'this caller';
|
||||||
|
append({
|
||||||
|
role: 'user',
|
||||||
|
content: `Give me a quick summary of ${name} — prior appointments, last disposition, any outstanding notes. If net-new, say so.`,
|
||||||
|
});
|
||||||
|
}, [callerContext?.leadId, callerContext?.leadName, append, setMessages]);
|
||||||
|
|
||||||
const handleQuickAction = (prompt: string) => {
|
const handleQuickAction = (prompt: string) => {
|
||||||
append({ role: 'user', content: prompt });
|
append({ role: 'user', content: prompt });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -287,7 +287,15 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
|||||||
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
|
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
|
||||||
{followUpNeeded && (
|
{followUpNeeded && (
|
||||||
<div className="flex-1 max-w-[180px]">
|
<div className="flex-1 max-w-[180px]">
|
||||||
<Input type="date" value={followUpDate} onChange={setFollowUpDate} isRequired aria-label="Follow-up Date" />
|
<input
|
||||||
|
type="date"
|
||||||
|
value={followUpDate}
|
||||||
|
min={new Date().toISOString().split('T')[0]}
|
||||||
|
onChange={(e) => setFollowUpDate(e.target.value)}
|
||||||
|
required
|
||||||
|
aria-label="Follow-up Date"
|
||||||
|
className="w-full rounded-lg border border-primary bg-primary px-3 py-2 text-sm text-primary outline-none focus:border-brand-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { cx } from '@/utils/cx';
|
|||||||
export const CallDeskPage = () => {
|
export const CallDeskPage = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
|
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
|
||||||
const { callState, callerNumber, callUcid, dialOutbound } = useSip();
|
const { callState, callerNumber, callUcid, dialOutbound, isRegistered } = useSip();
|
||||||
const { missedCalls, followUps, marketingLeads, loading } = useWorklist();
|
const { missedCalls, followUps, marketingLeads, loading } = useWorklist();
|
||||||
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
||||||
const [contextOpen, setContextOpen] = useState(true);
|
const [contextOpen, setContextOpen] = useState(true);
|
||||||
@@ -204,11 +204,11 @@ export const CallDeskPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleDial}
|
onClick={handleDial}
|
||||||
disabled={dialling || dialNumber.replace(/[^0-9]/g, '').length < 10}
|
disabled={!isRegistered || dialling || dialNumber.replace(/[^0-9]/g, '').length < 10}
|
||||||
className="w-full flex items-center justify-center gap-2 rounded-lg bg-success-solid py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:bg-disabled disabled:cursor-not-allowed transition duration-100 ease-linear"
|
className="w-full flex items-center justify-center gap-2 rounded-lg bg-success-solid py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:bg-disabled disabled:cursor-not-allowed transition duration-100 ease-linear"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPhone} className="size-3.5" />
|
<FontAwesomeIcon icon={faPhone} className="size-3.5" />
|
||||||
{dialling ? 'Dialling...' : 'Call'}
|
{dialling ? 'Dialling...' : !isRegistered ? 'Telephony unavailable' : 'Call'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { faIcon } from '@/lib/icon-wrapper';
|
|||||||
|
|
||||||
const SearchLg = faIcon(faMagnifyingGlass);
|
const SearchLg = faIcon(faMagnifyingGlass);
|
||||||
import { Avatar } from '@/components/base/avatar/avatar';
|
import { Avatar } from '@/components/base/avatar/avatar';
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
|
||||||
// Button removed — actions are icon-only now
|
// Button removed — actions are icon-only now
|
||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
import { Table, TableCard } from '@/components/application/table/table';
|
import { Table, TableCard } from '@/components/application/table/table';
|
||||||
@@ -134,7 +133,6 @@ export const PatientsPage = () => {
|
|||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Head label="PATIENT" isRowHeader />
|
<Table.Head label="PATIENT" isRowHeader />
|
||||||
<Table.Head label="CONTACT" />
|
<Table.Head label="CONTACT" />
|
||||||
<Table.Head label="TYPE" />
|
|
||||||
<Table.Head label="GENDER" />
|
<Table.Head label="GENDER" />
|
||||||
<Table.Head label="AGE" />
|
<Table.Head label="AGE" />
|
||||||
<Table.Head label="ACTIONS" />
|
<Table.Head label="ACTIONS" />
|
||||||
@@ -196,17 +194,6 @@ export const PatientsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
{/* Type */}
|
|
||||||
<Table.Cell>
|
|
||||||
{patient.patientType ? (
|
|
||||||
<Badge size="sm" color="gray">
|
|
||||||
{patient.patientType}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-placeholder">—</span>
|
|
||||||
)}
|
|
||||||
</Table.Cell>
|
|
||||||
|
|
||||||
{/* Gender */}
|
{/* Gender */}
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-sm text-secondary">
|
<span className="text-sm text-secondary">
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
|
|||||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
||||||
const setCallDuration = useSetAtom(sipCallDurationAtom);
|
const setCallDuration = useSetAtom(sipCallDurationAtom);
|
||||||
const setCallStartTime = useSetAtom(sipCallStartTimeAtom);
|
const setCallStartTime = useSetAtom(sipCallStartTimeAtom);
|
||||||
|
const setIsMutedGlobal = useSetAtom(sipIsMutedAtom);
|
||||||
|
const setIsOnHoldGlobal = useSetAtom(sipIsOnHoldAtom);
|
||||||
|
|
||||||
// Register Jotai setters so the singleton SIP manager can update atoms
|
// Register Jotai setters so the singleton SIP manager can update atoms
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,8 +53,10 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
|
|||||||
setCallState,
|
setCallState,
|
||||||
setCallerNumber,
|
setCallerNumber,
|
||||||
setCallUcid,
|
setCallUcid,
|
||||||
|
setIsMuted: setIsMutedGlobal,
|
||||||
|
setIsOnHold: setIsOnHoldGlobal,
|
||||||
});
|
});
|
||||||
}, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid]);
|
}, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid, setIsMutedGlobal, setIsOnHoldGlobal]);
|
||||||
|
|
||||||
// Auto-connect SIP on mount — only if Agent entity has SIP config
|
// Auto-connect SIP on mount — only if Agent entity has SIP config
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ type StateUpdater = {
|
|||||||
setCallState: (state: CallState) => void;
|
setCallState: (state: CallState) => void;
|
||||||
setCallerNumber: (number: string | null) => void;
|
setCallerNumber: (number: string | null) => void;
|
||||||
setCallUcid: (ucid: string | null) => void;
|
setCallUcid: (ucid: string | null) => void;
|
||||||
|
setIsMuted: (muted: boolean) => void;
|
||||||
|
setIsOnHold: (onHold: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let stateUpdater: StateUpdater | null = null;
|
let stateUpdater: StateUpdater | null = null;
|
||||||
@@ -83,7 +85,13 @@ export function connectSip(config: SIPConfig): void {
|
|||||||
if (ucid) stateUpdater?.setCallUcid(ucid);
|
if (ucid) stateUpdater?.setCallUcid(ucid);
|
||||||
|
|
||||||
if (state === 'ended' || state === 'failed') {
|
if (state === 'ended' || state === 'failed') {
|
||||||
sipClient?.unmute(); // clear any mute state so it doesn't persist to next call
|
// Reset both the SIP track AND the Recoil state — otherwise the
|
||||||
|
// UI icon + toggle-mute branch logic stay "muted" and the next
|
||||||
|
// call opens in a confusing half-muted state.
|
||||||
|
sipClient?.unmute();
|
||||||
|
sipClient?.unhold();
|
||||||
|
stateUpdater?.setIsMuted(false);
|
||||||
|
stateUpdater?.setIsOnHold(false);
|
||||||
outboundActive = false;
|
outboundActive = false;
|
||||||
outboundPending = false;
|
outboundPending = false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user