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 {
|
import {
|
||||||
Phone01,
|
Phone01,
|
||||||
PhoneIncoming01,
|
PhoneIncoming01,
|
||||||
@@ -101,24 +101,146 @@ export const CallWidget = () => {
|
|||||||
const [disposition, setDisposition] = useState<CallDisposition | null>(null);
|
const [disposition, setDisposition] = useState<CallDisposition | null>(null);
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [lastDuration, setLastDuration] = useState(0);
|
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(() => {
|
useEffect(() => {
|
||||||
if (callState === 'active' && callDuration > 0) {
|
if (callState === 'active' && callDuration > 0) {
|
||||||
setLastDuration(callDuration);
|
setLastDuration(callDuration);
|
||||||
}
|
}
|
||||||
}, [callState, 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(() => {
|
useEffect(() => {
|
||||||
if (callState === 'idle') {
|
if (callState === 'idle') {
|
||||||
setDisposition(null);
|
setDisposition(null);
|
||||||
setNotes('');
|
setNotes('');
|
||||||
|
setMatchedLead(null);
|
||||||
|
setLeadActivities([]);
|
||||||
}
|
}
|
||||||
}, [callState]);
|
}, [callState]);
|
||||||
|
|
||||||
const handleSaveAndClose = () => {
|
const handleSaveAndClose = async () => {
|
||||||
// TODO: Wire to BFF to create Call record + update Lead
|
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();
|
hangup();
|
||||||
setDisposition(null);
|
setDisposition(null);
|
||||||
setNotes('');
|
setNotes('');
|
||||||
@@ -227,8 +349,42 @@ export const CallWidget = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Caller number */}
|
{/* Caller info */}
|
||||||
<span className="text-lg font-bold text-primary">{callerNumber ?? 'Unknown'}</span>
|
<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 */}
|
{/* Call controls */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -290,11 +446,12 @@ export const CallWidget = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
color="primary"
|
color="primary"
|
||||||
iconLeading={Save01}
|
iconLeading={Save01}
|
||||||
isDisabled={disposition === null}
|
isDisabled={disposition === null || isSaving}
|
||||||
|
isLoading={isSaving}
|
||||||
onClick={handleSaveAndClose}
|
onClick={handleSaveAndClose}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
Save & Close
|
{isSaving ? 'Saving...' : 'Save & Close'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user