Files
helix-engage/src/hooks/use-worklist.ts
saridsa2 df08bcfc19 feat: SSE-driven worklist + agent call history split + remove SOURCE column
Worklist:
- SSE stream replaces 30s poll — EventSource on /api/supervisor/worklist/stream
  triggers immediate fetchWorklist() on missed-call events
- Toast notification: 'Missed Call — {name} — needs callback'
- No polling fallback — SSE is the source of truth

Call History split by role:
- Agent: 'My Call History' — own calls only (matched by agent relation
  or chain-parsed agentName), missed calls excluded (they belong on
  the Call Desk queue), no Agent/Recording/SLA columns, phone clickable
  via PhoneActionCell instead of separate Call button
- Supervisor: 'Call History' — all calls, Agent + Recording columns visible

Worklist panel:
- SOURCE/BRANCH column removed from display (data stays on row)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:34:37 +05:30

165 lines
5.6 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
type MissedCall = {
id: string;
createdAt: string;
callDirection: string | null;
callStatus: string | null;
callerNumber: { number: string; callingCode: string }[] | null;
agentName: string | null;
startedAt: string | null;
endedAt: string | null;
durationSeconds: number | null;
disposition: string | null;
callNotes: string | null;
leadId: string | null;
leadName: string | null;
callbackStatus: string | null;
callSourceNumber: string | null;
missedCallCount: number | null;
callbackAttemptedAt: string | null;
};
type WorklistFollowUp = {
id: string;
createdAt: string | null;
followUpType: string | null;
followUpStatus: string | null;
scheduledAt: string | null;
completedAt: string | null;
priority: string | null;
assignedAgent: string | null;
patientId: string | null;
callId: string | null;
patientName?: string;
patientPhone?: string;
};
type WorklistLead = {
id: string;
createdAt: string;
contactName: { firstName: string; lastName: string } | null;
contactPhone: { number: string; callingCode: string }[] | null;
contactEmail: { address: string }[] | null;
leadSource: string | null;
leadStatus: string | null;
interestedService: string | null;
assignedAgent: string | null;
campaignId: string | null;
adId: string | null;
contactAttempts: number | null;
spamScore: number | null;
isSpam: boolean | null;
aiSummary: string | null;
aiSuggestedAction: string | null;
lastContacted: string | null;
utmCampaign: string | null;
};
type WorklistData = {
missedCalls: MissedCall[];
followUps: WorklistFollowUp[];
marketingLeads: WorklistLead[];
totalPending: number;
};
type UseWorklistResult = WorklistData & {
loading: boolean;
error: string | null;
refresh: () => void;
};
const EMPTY_WORKLIST: WorklistData = {
missedCalls: [],
followUps: [],
marketingLeads: [],
totalPending: 0,
};
export const useWorklist = (): UseWorklistResult => {
const [data, setData] = useState<WorklistData>(EMPTY_WORKLIST);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchWorklist = useCallback(async () => {
if (!apiClient.isAuthenticated()) {
setError('Not authenticated');
setLoading(false);
return;
}
try {
const json = await apiClient.get<any>('/api/worklist', { silent: true });
// Transform platform field shapes to frontend types
const transformed: WorklistData = {
...json,
marketingLeads: (json.marketingLeads ?? []).map((lead: any) => ({
...lead,
leadSource: lead.source ?? lead.leadSource,
leadStatus: lead.status ?? lead.leadStatus,
contactPhone: lead.contactPhone?.primaryPhoneNumber
? [{ number: lead.contactPhone.primaryPhoneNumber, callingCode: lead.contactPhone.primaryPhoneCallingCode ?? '+91' }]
: lead.contactPhone,
contactEmail: lead.contactEmail?.primaryEmail
? [{ address: lead.contactEmail.primaryEmail }]
: lead.contactEmail,
})),
missedCalls: (json.missedCalls ?? []).map((call: any) => ({
...call,
callDirection: call.direction ?? call.callDirection,
durationSeconds: call.durationSec ?? call.durationSeconds ?? 0,
callerNumber: call.callerNumber?.primaryPhoneNumber
? [{ number: call.callerNumber.primaryPhoneNumber, callingCode: '+91' }]
: call.callerNumber,
})),
followUps: (json.followUps ?? []).map((fu: any) => ({
...fu,
followUpType: fu.typeCustom ?? fu.followUpType,
followUpStatus: fu.status ?? fu.followUpStatus,
})),
};
setData(transformed);
setError(null);
} catch {
setError('Sidecar not reachable');
}
setLoading(false);
}, []);
useEffect(() => {
fetchWorklist();
// SSE stream for instant worklist updates. No polling fallback —
// if SSE breaks, the worklist stops updating and we fix the SSE,
// not paper over it with a poll.
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
let es: EventSource | null = null;
try {
es = new EventSource(`${API_URL}/api/supervisor/worklist/stream`);
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('[WORKLIST-SSE]', data);
fetchWorklist();
if (data.type === 'missed-call') {
const name = data.callerName ?? data.callerPhone ?? 'Unknown';
notify.warning('Missed Call', `${name} — needs callback`);
}
} catch {}
};
es.onerror = () => {
console.warn('[WORKLIST-SSE] Connection error — EventSource will auto-reconnect');
};
} catch {}
return () => { es?.close(); };
}, [fetchWorklist]);
return { ...data, loading, error, refresh: fetchWorklist };
};