feat: wire call logging to platform — disposition creates Call record, updates Lead status, logs LeadActivity

This commit is contained in:
2026-03-18 09:11:15 +05:30
parent d846d97377
commit 832fa31597

View File

@@ -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>