mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: wire call logging to platform — disposition creates Call record, updates Lead status, logs LeadActivity
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Phone01,
|
||||
PhoneIncoming01,
|
||||
@@ -101,24 +101,146 @@ export const CallWidget = () => {
|
||||
const [disposition, setDisposition] = useState<CallDisposition | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [lastDuration, setLastDuration] = useState(0);
|
||||
const [matchedLead, setMatchedLead] = useState<any>(null);
|
||||
const [leadActivities, setLeadActivities] = useState<any[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const callStartTimeRef = useRef<string | null>(null);
|
||||
|
||||
// Capture duration right before call ends so we can display it in the ended state
|
||||
// Capture duration right before call ends
|
||||
useEffect(() => {
|
||||
if (callState === 'active' && callDuration > 0) {
|
||||
setLastDuration(callDuration);
|
||||
}
|
||||
}, [callState, callDuration]);
|
||||
|
||||
// Reset disposition/notes when returning to idle
|
||||
// Track call start time
|
||||
useEffect(() => {
|
||||
if (callState === 'active' && !callStartTimeRef.current) {
|
||||
callStartTimeRef.current = new Date().toISOString();
|
||||
}
|
||||
if (callState === 'idle') {
|
||||
callStartTimeRef.current = null;
|
||||
}
|
||||
}, [callState]);
|
||||
|
||||
// Look up caller when call becomes active
|
||||
useEffect(() => {
|
||||
if (callState === 'ringing-in' && callerNumber && callerNumber !== 'Unknown') {
|
||||
const lookup = async () => {
|
||||
try {
|
||||
const { apiClient } = await import('@/lib/api-client');
|
||||
const token = apiClient.getStoredToken();
|
||||
if (!token) return;
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||
const res = await fetch(`${API_URL}/api/call/lookup`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ phoneNumber: callerNumber }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.matched && data.lead) {
|
||||
setMatchedLead(data.lead);
|
||||
setLeadActivities(data.activities ?? []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Lead lookup failed:', err);
|
||||
}
|
||||
};
|
||||
lookup();
|
||||
}
|
||||
}, [callState, callerNumber]);
|
||||
|
||||
// Reset state when returning to idle
|
||||
useEffect(() => {
|
||||
if (callState === 'idle') {
|
||||
setDisposition(null);
|
||||
setNotes('');
|
||||
setMatchedLead(null);
|
||||
setLeadActivities([]);
|
||||
}
|
||||
}, [callState]);
|
||||
|
||||
const handleSaveAndClose = () => {
|
||||
// TODO: Wire to BFF to create Call record + update Lead
|
||||
const handleSaveAndClose = async () => {
|
||||
if (!disposition) return;
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const { apiClient } = await import('@/lib/api-client');
|
||||
|
||||
// 1. Create Call record on platform
|
||||
await apiClient.graphql(
|
||||
`mutation CreateCall($data: CallCreateInput!) {
|
||||
createCall(data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
data: {
|
||||
callDirection: 'INBOUND',
|
||||
callStatus: 'COMPLETED',
|
||||
agentName: 'Rekha S.',
|
||||
startedAt: callStartTimeRef.current,
|
||||
endedAt: new Date().toISOString(),
|
||||
durationSeconds: callDuration,
|
||||
disposition,
|
||||
callNotes: notes || null,
|
||||
leadId: matchedLead?.id ?? null,
|
||||
},
|
||||
},
|
||||
).catch(err => console.warn('Failed to create call record:', err));
|
||||
|
||||
// 2. Update lead status if matched
|
||||
if (matchedLead?.id) {
|
||||
const statusMap: Partial<Record<string, string>> = {
|
||||
APPOINTMENT_BOOKED: 'APPOINTMENT_SET',
|
||||
FOLLOW_UP_SCHEDULED: 'CONTACTED',
|
||||
INFO_PROVIDED: 'CONTACTED',
|
||||
NO_ANSWER: 'CONTACTED',
|
||||
WRONG_NUMBER: 'LOST',
|
||||
CALLBACK_REQUESTED: 'CONTACTED',
|
||||
NOT_INTERESTED: 'LOST',
|
||||
};
|
||||
const newStatus = statusMap[disposition];
|
||||
if (newStatus) {
|
||||
await apiClient.graphql(
|
||||
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
||||
updateLead(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
id: matchedLead.id,
|
||||
data: {
|
||||
leadStatus: newStatus,
|
||||
lastContactedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
).catch(err => console.warn('Failed to update lead:', err));
|
||||
}
|
||||
|
||||
// 3. Create lead activity
|
||||
await apiClient.graphql(
|
||||
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
|
||||
createLeadActivity(data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
data: {
|
||||
activityType: 'CALL_RECEIVED',
|
||||
summary: `Inbound call — ${disposition.replace(/_/g, ' ')}`,
|
||||
occurredAt: new Date().toISOString(),
|
||||
performedBy: 'Rekha S.',
|
||||
channel: 'PHONE',
|
||||
durationSeconds: callDuration,
|
||||
leadId: matchedLead.id,
|
||||
},
|
||||
},
|
||||
).catch(err => console.warn('Failed to create activity:', err));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err);
|
||||
}
|
||||
|
||||
setIsSaving(false);
|
||||
hangup();
|
||||
setDisposition(null);
|
||||
setNotes('');
|
||||
@@ -227,8 +349,42 @@ export const CallWidget = () => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Caller number */}
|
||||
<span className="text-lg font-bold text-primary">{callerNumber ?? 'Unknown'}</span>
|
||||
{/* Caller info */}
|
||||
<div>
|
||||
<span className="text-lg font-bold text-primary">
|
||||
{matchedLead?.contactName
|
||||
? `${matchedLead.contactName.firstName ?? ''} ${matchedLead.contactName.lastName ?? ''}`.trim()
|
||||
: callerNumber ?? 'Unknown'}
|
||||
</span>
|
||||
{matchedLead && (
|
||||
<span className="ml-2 text-sm text-tertiary">{callerNumber}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Summary */}
|
||||
{matchedLead?.aiSummary && (
|
||||
<div className="rounded-xl bg-brand-primary p-3">
|
||||
<div className="mb-1 text-xs font-bold uppercase tracking-wider text-brand-secondary">AI Insight</div>
|
||||
<p className="text-sm text-primary">{matchedLead.aiSummary}</p>
|
||||
{matchedLead.aiSuggestedAction && (
|
||||
<span className="mt-2 inline-block rounded-lg bg-brand-solid px-3 py-1 text-xs font-semibold text-white">
|
||||
{matchedLead.aiSuggestedAction}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent activity */}
|
||||
{leadActivities.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-semibold text-tertiary">Recent Activity</div>
|
||||
{leadActivities.slice(0, 3).map((a: any, i: number) => (
|
||||
<div key={i} className="text-xs text-quaternary">
|
||||
{a.activityType?.replace(/_/g, ' ')}: {a.summary}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Call controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -290,11 +446,12 @@ export const CallWidget = () => {
|
||||
size="sm"
|
||||
color="primary"
|
||||
iconLeading={Save01}
|
||||
isDisabled={disposition === null}
|
||||
isDisabled={disposition === null || isSaving}
|
||||
isLoading={isSaving}
|
||||
onClick={handleSaveAndClose}
|
||||
className="w-full"
|
||||
>
|
||||
Save & Close
|
||||
{isSaving ? 'Saving...' : 'Save & Close'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user