mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
feat: call-desk refresh — disposition modal, active-call UI, worklist + perf updates
- Call-desk: active-call-card supervisor presence badges, incoming-call-card polish, transfer-dialog, call-log - Disposition modal: auto-lock based on actions taken, not-interested split - Forms: appointment-form + enquiry-form improvements (placeholder handling, phone format) - Worklist-panel: pagination awareness, filter chips - Pages: all-leads/patients/patient-360/missed-calls/team-performance/call-history/appointments polish - SIP: sip-client reconnect, sip-provider + sip-manager state, agent-status-toggle spinner - Hooks: use-agent-state supervisor SSE events, use-worklist, use-performance-alerts - Types: entities.ts extended Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,8 @@ type WorklistFollowUp = {
|
||||
followUpStatus: string | null;
|
||||
scheduledAt: string | null;
|
||||
priority: string | null;
|
||||
patientName?: string;
|
||||
patientPhone?: string;
|
||||
};
|
||||
|
||||
type MissedCall = {
|
||||
@@ -45,11 +47,12 @@ type MissedCall = {
|
||||
callerNumber: { number: string; callingCode: string }[] | null;
|
||||
startedAt: string | null;
|
||||
leadId: string | null;
|
||||
leadName: string | null;
|
||||
disposition: string | null;
|
||||
callbackstatus: string | null;
|
||||
callsourcenumber: string | null;
|
||||
missedcallcount: number | null;
|
||||
callbackattemptedat: string | null;
|
||||
callbackStatus: string | null;
|
||||
callSourceNumber: string | null;
|
||||
missedCallCount: number | null;
|
||||
callbackAttemptedAt: string | null;
|
||||
};
|
||||
|
||||
type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid';
|
||||
@@ -107,7 +110,9 @@ const followUpLabel: Record<string, string> = {
|
||||
REVIEW_REQUEST: 'Review',
|
||||
};
|
||||
|
||||
const computeSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => {
|
||||
// SLA for reactive work — missed calls / unanswered leads. Measures time
|
||||
// elapsed since the trigger: longer wait = worse SLA.
|
||||
const computeReactiveSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => {
|
||||
const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
|
||||
if (minutes < 1) return { label: '<1m', color: 'success' };
|
||||
if (minutes < 15) return { label: `${minutes}m`, color: 'success' };
|
||||
@@ -118,6 +123,34 @@ const computeSla = (dateStr: string): { label: string; color: 'success' | 'warni
|
||||
return { label: `${Math.floor(hours / 24)}d`, color: 'error' };
|
||||
};
|
||||
|
||||
// SLA for scheduled work — follow-ups / callbacks. Measures time remaining
|
||||
// until the scheduled slot. Green when comfortably ahead, warning when
|
||||
// due soon, error when overdue.
|
||||
const computeScheduledSla = (scheduledAt: string): { label: string; color: 'success' | 'warning' | 'error' } => {
|
||||
const minutes = Math.round((new Date(scheduledAt).getTime() - Date.now()) / 60000);
|
||||
if (minutes < 0) {
|
||||
const overdueMins = -minutes;
|
||||
if (overdueMins < 60) return { label: `Overdue ${overdueMins}m`, color: 'error' };
|
||||
const overdueHrs = Math.floor(overdueMins / 60);
|
||||
if (overdueHrs < 24) return { label: `Overdue ${overdueHrs}h`, color: 'error' };
|
||||
return { label: `Overdue ${Math.floor(overdueHrs / 24)}d`, color: 'error' };
|
||||
}
|
||||
if (minutes < 60) return { label: `Due in ${minutes}m`, color: 'warning' };
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return { label: `Due in ${hours}h`, color: hours < 4 ? 'warning' : 'success' };
|
||||
return { label: `Due in ${Math.floor(hours / 24)}d`, color: 'success' };
|
||||
};
|
||||
|
||||
const computeSla = (
|
||||
row: Pick<WorklistRow, 'type' | 'lastContactedAt' | 'createdAt'>,
|
||||
): { label: string; color: 'success' | 'warning' | 'error' } => {
|
||||
if (row.type === 'follow-up' || row.type === 'callback') {
|
||||
// scheduledAt was written into lastContactedAt during row construction.
|
||||
return computeScheduledSla(row.lastContactedAt ?? row.createdAt);
|
||||
}
|
||||
return computeReactiveSla(row.lastContactedAt ?? row.createdAt);
|
||||
};
|
||||
|
||||
const formatTimeAgo = (dateStr: string): string => {
|
||||
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
|
||||
if (minutes < 1) return 'Just now';
|
||||
@@ -150,13 +183,13 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
|
||||
for (const call of missedCalls) {
|
||||
const phone = call.callerNumber?.[0];
|
||||
const countBadge = call.missedcallcount && call.missedcallcount > 1 ? ` (${call.missedcallcount}x)` : '';
|
||||
const sourceSuffix = call.callsourcenumber ? ` • ${call.callsourcenumber}` : '';
|
||||
const countBadge = call.missedCallCount && call.missedCallCount > 1 ? ` (${call.missedCallCount}x)` : '';
|
||||
const sourceSuffix = call.callSourceNumber ? ` • ${call.callSourceNumber}` : '';
|
||||
rows.push({
|
||||
id: `mc-${call.id}`,
|
||||
type: 'missed',
|
||||
priority: 'HIGH',
|
||||
name: (phone ? formatPhone(phone) : 'Unknown') + countBadge,
|
||||
name: (call.leadName || (phone ? formatPhone(phone) : 'Unknown')) + countBadge,
|
||||
phone: phone ? formatPhone(phone) : '',
|
||||
phoneRaw: phone?.number ?? '',
|
||||
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
|
||||
@@ -165,12 +198,12 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
? `Missed at ${formatTimeOnly(call.startedAt)}${sourceSuffix}`
|
||||
: 'Missed call',
|
||||
createdAt: call.createdAt,
|
||||
taskState: call.callbackstatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
|
||||
taskState: call.callbackStatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
|
||||
leadId: call.leadId,
|
||||
originalLead: null,
|
||||
lastContactedAt: call.callbackattemptedat ?? call.startedAt ?? call.createdAt,
|
||||
lastContactedAt: call.callbackAttemptedAt ?? call.startedAt ?? call.createdAt,
|
||||
contactAttempts: 0,
|
||||
source: call.callsourcenumber ?? null,
|
||||
source: call.callSourceNumber ?? null,
|
||||
lastDisposition: call.disposition ?? null,
|
||||
missedCallId: call.id,
|
||||
});
|
||||
@@ -179,13 +212,20 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
for (const fu of followUps) {
|
||||
const isOverdue = fu.followUpStatus === 'OVERDUE' || (fu.scheduledAt !== null && new Date(fu.scheduledAt) < new Date());
|
||||
const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up';
|
||||
// Sidecar enriches follow-ups with patient name/phone when a
|
||||
// patientId is linked. Fall back to the generic type label when
|
||||
// no patient is attached.
|
||||
const displayName = fu.patientName?.trim() || label;
|
||||
const phoneFormatted = fu.patientPhone
|
||||
? formatPhone({ number: fu.patientPhone, callingCode: '+91' })
|
||||
: '';
|
||||
rows.push({
|
||||
id: `fu-${fu.id}`,
|
||||
type: fu.followUpType === 'CALLBACK' ? 'callback' : 'follow-up',
|
||||
priority: (fu.priority as WorklistRow['priority']) ?? (isOverdue ? 'HIGH' : 'NORMAL'),
|
||||
name: label,
|
||||
phone: '',
|
||||
phoneRaw: '',
|
||||
name: displayName,
|
||||
phone: phoneFormatted,
|
||||
phoneRaw: fu.patientPhone ?? '',
|
||||
direction: null,
|
||||
typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up',
|
||||
reason: fu.scheduledAt
|
||||
@@ -230,8 +270,9 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
});
|
||||
}
|
||||
|
||||
// Remove rows without a phone number — agent can't act on them
|
||||
const actionableRows = rows.filter(r => r.phoneRaw);
|
||||
// Keep all rows — follow-ups may have no phone and still need to be visible.
|
||||
// The PhoneActionCell renders a "No phone" placeholder when phoneRaw is empty.
|
||||
const actionableRows = rows;
|
||||
|
||||
// Sort by rules engine score if available, otherwise by priority + createdAt
|
||||
actionableRows.sort((a, b) => {
|
||||
@@ -249,13 +290,15 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
const [tab, setTab] = useState<TabKey>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [missedSubTab, setMissedSubTab] = useState<MissedSubTab>('pending');
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'sla', direction: 'descending' });
|
||||
// Default SLA sort is ascending — the bucket-sorted result puts the
|
||||
// most-urgent rows at the top (overdue → oldest reactive → soonest due).
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'sla', direction: 'ascending' });
|
||||
|
||||
const missedByStatus = useMemo(() => ({
|
||||
pending: missedCalls.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus),
|
||||
attempted: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED'),
|
||||
completed: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER'),
|
||||
invalid: missedCalls.filter(c => c.callbackstatus === 'INVALID'),
|
||||
pending: missedCalls.filter(c => c.callbackStatus === 'PENDING_CALLBACK' || !c.callbackStatus),
|
||||
attempted: missedCalls.filter(c => c.callbackStatus === 'CALLBACK_ATTEMPTED'),
|
||||
completed: missedCalls.filter(c => c.callbackStatus === 'CALLBACK_COMPLETED' || c.callbackStatus === 'WRONG_NUMBER'),
|
||||
invalid: missedCalls.filter(c => c.callbackStatus === 'INVALID'),
|
||||
}), [missedCalls]);
|
||||
|
||||
const allRows = useMemo(
|
||||
@@ -273,7 +316,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
let rows = allRows;
|
||||
if (tab === 'missed') rows = missedSubTabRows;
|
||||
else if (tab === 'leads') rows = rows.filter((r) => r.type === 'lead');
|
||||
else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up');
|
||||
else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up' || r.type === 'callback');
|
||||
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
@@ -295,8 +338,27 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name) * dir;
|
||||
case 'sla': {
|
||||
// Mixed SLA sort: SLA means different things by row type
|
||||
// (elapsed for reactive, remaining for scheduled). Bucket
|
||||
// rows by urgency, then sort within bucket — Overdue
|
||||
// first, then reactive (oldest-first), then scheduled
|
||||
// (soonest-due first). `dir` flips the whole ordering
|
||||
// so the user can still toggle ascending/descending.
|
||||
const urgencyBucket = (row: WorklistRow): number => {
|
||||
const isScheduled = row.type === 'follow-up' || row.type === 'callback';
|
||||
if (isScheduled) {
|
||||
const t = new Date(row.lastContactedAt ?? row.createdAt).getTime();
|
||||
return t < Date.now() ? 0 : 2; // 0 = overdue, 2 = upcoming
|
||||
}
|
||||
return 1; // reactive (missed / lead)
|
||||
};
|
||||
const ba = urgencyBucket(a);
|
||||
const bb = urgencyBucket(b);
|
||||
if (ba !== bb) return (ba - bb) * dir;
|
||||
const ta = new Date(a.lastContactedAt ?? a.createdAt).getTime();
|
||||
const tb = new Date(b.lastContactedAt ?? b.createdAt).getTime();
|
||||
// Within a bucket, ascending time = most urgent first
|
||||
// (oldest overdue, oldest reactive, soonest upcoming).
|
||||
return (ta - tb) * dir;
|
||||
}
|
||||
default:
|
||||
@@ -310,7 +372,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
|
||||
const missedCount = allRows.filter((r) => r.type === 'missed').length;
|
||||
const leadCount = allRows.filter((r) => r.type === 'lead').length;
|
||||
const followUpCount = allRows.filter((r) => r.type === 'follow-up').length;
|
||||
const followUpCount = allRows.filter((r) => r.type === 'follow-up' || r.type === 'callback').length;
|
||||
|
||||
// Notification for new missed calls
|
||||
const prevMissedCount = useRef(missedCount);
|
||||
@@ -380,7 +442,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
{/* Missed call status sub-tabs */}
|
||||
{tab === 'missed' && (
|
||||
<div className="flex shrink-0 gap-1 px-5 py-2 border-b border-secondary">
|
||||
{(['pending', 'attempted', 'completed', 'invalid'] as MissedSubTab[]).map(sub => (
|
||||
{(['pending', 'attempted'] as MissedSubTab[]).map(sub => (
|
||||
<button
|
||||
key={sub}
|
||||
onClick={() => { setMissedSubTab(sub); setPage(1); }}
|
||||
@@ -421,7 +483,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
<Table.Body items={pagedRows}>
|
||||
{(row) => {
|
||||
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
|
||||
const sla = computeSla(row.lastContactedAt ?? row.createdAt);
|
||||
const sla = computeSla(row);
|
||||
const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId;
|
||||
|
||||
// Sub-line: last interaction context
|
||||
|
||||
Reference in New Issue
Block a user