import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { faPhoneArrowDown, faPhoneArrowUp, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons'; import type { SortDescriptor } from 'react-aria-components'; import { faIcon } from '@/lib/icon-wrapper'; import { Table } from '@/components/application/table/table'; const SearchLg = faIcon(faMagnifyingGlass); import { Badge } from '@/components/base/badges/badges'; import { Input } from '@/components/base/input/input'; import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs'; import { PhoneActionCell } from './phone-action-cell'; import { formatPhone, formatTimeOnly, formatShortDate } from '@/lib/format'; import { notify } from '@/lib/toast'; import { cx } from '@/utils/cx'; type WorklistLead = { id: string; createdAt: string; contactName: { firstName: string; lastName: string } | null; contactPhone: { number: string; callingCode: string }[] | null; leadSource: string | null; leadStatus: string | null; interestedService: string | null; aiSummary: string | null; aiSuggestedAction: string | null; lastContacted: string | null; contactAttempts: number | null; utmCampaign: string | null; campaignId: string | null; }; type WorklistFollowUp = { id: string; createdAt: string | null; followUpType: string | null; followUpStatus: string | null; scheduledAt: string | null; priority: string | null; patientId?: string | null; patientName?: string; patientPhone?: string; }; type MissedCall = { id: string; createdAt: string; callDirection: string | null; 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; campaign?: { id: string; campaignName: string } | null; }; type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid'; // Generic selection from any worklist row — the call-desk resolves // lead/patient context from whatever is available on the row. export type WorklistSelection = { rowId: string; type: 'missed' | 'callback' | 'follow-up' | 'lead'; lead: WorklistLead | null; phoneRaw: string | null; patientId: string | null; leadId: string | null; name: string; }; interface WorklistPanelProps { missedCalls: MissedCall[]; followUps: WorklistFollowUp[]; leads: WorklistLead[]; loading: boolean; onSelectItem: (selection: WorklistSelection) => void; selectedItemId: string | null; onDialMissedCall?: (missedCallId: string) => void; } type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups'; type WorklistRow = { id: string; type: 'missed' | 'callback' | 'follow-up' | 'lead'; priority: 'URGENT' | 'HIGH' | 'NORMAL' | 'LOW'; name: string; phone: string; phoneRaw: string; direction: 'inbound' | 'outbound' | null; typeLabel: string; reason: string; createdAt: string; taskState: 'PENDING' | 'ATTEMPTED' | 'SCHEDULED'; leadId: string | null; patientId: string | null; originalLead: WorklistLead | null; lastContactedAt: string | null; contactAttempts: number; source: string | null; lastDisposition: string | null; missedCallId: string | null; // Rules engine scoring (from sidecar) score?: number; scoreBreakdown?: { baseScore: number; slaMultiplier: number; campaignMultiplier: number; rulesApplied: string[] }; slaStatus?: 'low' | 'medium' | 'high' | 'critical'; slaElapsedPercent?: number; }; const priorityConfig: Record = { URGENT: { color: 'error', label: 'Urgent', sort: 0 }, HIGH: { color: 'warning', label: 'High', sort: 1 }, NORMAL: { color: 'brand', label: 'Normal', sort: 2 }, LOW: { color: 'gray', label: 'Low', sort: 3 }, }; const followUpLabel: Record = { CALLBACK: 'Callback', APPOINTMENT_REMINDER: 'Appt Reminder', POST_VISIT: 'Post-visit', MARKETING: 'Marketing', REVIEW_REQUEST: 'Review', }; // 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' }; if (minutes < 30) return { label: `${minutes}m`, color: 'warning' }; if (minutes < 60) return { label: `${minutes}m`, color: 'error' }; const hours = Math.floor(minutes / 60); if (hours < 24) return { label: `${hours}h ${minutes % 60}m`, color: 'error' }; 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, ): { 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'; if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; return `${Math.floor(hours / 24)}d ago`; }; const formatDisposition = (disposition: string): string => disposition.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); const formatSource = (source: string): string => { const map: Record = { FACEBOOK_AD: 'Facebook', GOOGLE_AD: 'Google', WALK_IN: 'Walk-in', REFERRAL: 'Referral', WEBSITE: 'Website', PHONE_INQUIRY: 'Phone', PHONE: 'Phone', OTHER: 'Other', }; return map[source] ?? source.replace(/_/g, ' '); }; // Resolve a DID (e.g. "918041763265") to a friendly branch/campaign name. // The DID is the phone number the caller dialed — it identifies which // hospital branch or campaign the call came through. Falls back to the // last 10 digits if no mapping is found. const formatDid = (did: string): string => { if (!did) return '—'; // Known DIDs — loaded from the sidecar theme tokens at boot (via // the ThemeTokenProvider). For now, hardcode nothing — strip country // code and show the DID as a short number so it's at least readable. const digits = did.replace(/\D/g, ''); // Strip leading 91 (India) for display const short = digits.length > 10 && digits.startsWith('91') ? digits.slice(2) : digits; return short; }; const IconInbound = faIcon(faPhoneArrowDown); const IconOutbound = faIcon(faPhoneArrowUp); const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], leads: WorklistLead[]): WorklistRow[] => { const rows: WorklistRow[] = []; 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}` : ''; rows.push({ id: `mc-${call.id}`, type: 'missed', priority: 'HIGH', name: (call.leadName || (phone ? formatPhone(phone) : 'Unknown')) + countBadge, phone: phone ? formatPhone(phone) : '', phoneRaw: phone?.number ?? '', direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound', typeLabel: 'Missed Call', reason: call.startedAt ? `Missed at ${formatTimeOnly(call.startedAt)}${sourceSuffix}` : 'Missed call', createdAt: call.createdAt, taskState: call.callbackStatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING', leadId: call.leadId, patientId: (call as any).patientId ?? null, originalLead: null, lastContactedAt: call.callbackAttemptedAt ?? call.startedAt ?? call.createdAt, contactAttempts: 0, // Branch column: prefer the campaign name (e.g. "Cervical Cancer // Screening Drive") over the raw DID. Falls back to formatted DID // for organic calls with no campaign. source: call.campaign?.campaignName ?? (call.callSourceNumber ? formatDid(call.callSourceNumber) : null), lastDisposition: call.disposition ?? null, missedCallId: call.id, }); } 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: displayName, phone: phoneFormatted, phoneRaw: fu.patientPhone ?? '', direction: null, typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up', reason: fu.scheduledAt ? `Scheduled ${formatShortDate(fu.scheduledAt)}` : '', createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(), taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'), leadId: null, patientId: fu.patientId ?? null, originalLead: null, lastContactedAt: fu.scheduledAt ?? fu.createdAt ?? null, contactAttempts: 0, source: null, lastDisposition: null, missedCallId: null, }); } for (const lead of leads) { const firstName = lead.contactName?.firstName ?? ''; const lastName = lead.contactName?.lastName ?? ''; const fullName = `${firstName} ${lastName}`.trim() || 'Unknown'; const phone = lead.contactPhone?.[0]; rows.push({ id: `lead-${lead.id}`, type: 'lead', priority: 'NORMAL', name: fullName, phone: phone ? formatPhone(phone) : '', phoneRaw: phone?.number ?? '', direction: null, typeLabel: 'Lead', reason: lead.interestedService ?? lead.aiSuggestedAction ?? '', createdAt: lead.createdAt, taskState: 'PENDING', leadId: lead.id, patientId: (lead as any).patientId ?? null, originalLead: lead, lastContactedAt: lead.lastContacted ?? null, contactAttempts: lead.contactAttempts ?? 0, source: lead.leadSource ?? lead.utmCampaign ?? null, lastDisposition: null, missedCallId: null, }); } // 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) => { if (a.score != null && b.score != null) return b.score - a.score; const pa = priorityConfig[a.priority]?.sort ?? 2; const pb = priorityConfig[b.priority]?.sort ?? 2; if (pa !== pb) return pa - pb; return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); }); return actionableRows; }; export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectItem, selectedItemId, onDialMissedCall }: WorklistPanelProps) => { const [tab, setTab] = useState('all'); const [search, setSearch] = useState(''); // Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid // sub-tabs were removed per QA feedback — pending callbacks are the only // ones agents need to act on from the worklist. const missedSubTab: MissedSubTab = 'pending'; // 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({ 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'), }), [missedCalls]); const allRows = useMemo( () => buildRows(missedCalls, followUps, leads), [missedCalls, followUps, leads], ); // Build rows from sub-tab filtered missed calls when on missed tab const missedSubTabRows = useMemo( () => buildRows(missedByStatus[missedSubTab], [], []), [missedByStatus, missedSubTab], ); const filteredRows = useMemo(() => { 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' || r.type === 'callback'); if (search.trim()) { const q = search.toLowerCase(); rows = rows.filter( (r) => r.name.toLowerCase().includes(q) || r.phone.toLowerCase().includes(q) || r.phoneRaw.includes(q), ); } if (sortDescriptor.column) { const dir = sortDescriptor.direction === 'ascending' ? 1 : -1; rows = [...rows].sort((a, b) => { switch (sortDescriptor.column) { case 'priority': { if (a.score != null && b.score != null) return (a.score - b.score) * dir; const pa = priorityConfig[a.priority]?.sort ?? 2; const pb = priorityConfig[b.priority]?.sort ?? 2; return (pa - pb) * dir; } 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: return 0; } }); } return rows; }, [allRows, tab, search, sortDescriptor, missedSubTabRows]); 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' || r.type === 'callback').length; // Notification for new missed calls const prevMissedCount = useRef(missedCount); useEffect(() => { if (missedCount > prevMissedCount.current && prevMissedCount.current > 0) { notify.info('New Missed Call', `${missedCount - prevMissedCount.current} new missed call(s)`); } prevMissedCount.current = missedCount; }, [missedCount]); const PAGE_SIZE = 15; const [page, setPage] = useState(1); const handleTabChange = useCallback((key: TabKey) => { setTab(key); setPage(1); }, []); const handleSearch = useCallback((value: string) => { setSearch(value); setPage(1); }, []); const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE)); const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); const tabItems = [ { id: 'all' as const, label: 'All Tasks', badge: allRows.length > 0 ? String(allRows.length) : undefined }, { id: 'missed' as const, label: 'Missed Calls', badge: missedCount > 0 ? String(missedCount) : undefined }, { id: 'leads' as const, label: 'Leads', badge: leadCount > 0 ? String(leadCount) : undefined }, { id: 'follow-ups' as const, label: 'Follow-ups', badge: followUpCount > 0 ? String(followUpCount) : undefined }, ]; if (loading) { return (

Loading worklist...

); } const isEmpty = missedCalls.length === 0 && followUps.length === 0 && leads.length === 0; if (isEmpty) { return (

All clear

No pending items in your worklist

); } return (
{/* Filter tabs + search */}
handleTabChange(key as TabKey)}> {(item) => }
{/* Missed-call sub-tabs removed per QA feedback — the Missed tab now only shows pending callbacks. Attempted is redundant once the worklist is the single source of truth. */} {filteredRows.length === 0 ? (

{search ? 'No matching items' : 'No items in this category'}

) : (
{(row) => { const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL; const sla = computeSla(row); const isSelected = row.id === selectedItemId; // Sub-line: last interaction context const subLine = row.lastContactedAt ? `${formatTimeAgo(row.lastContactedAt)}${row.lastDisposition ? ` — ${formatDisposition(row.lastDisposition)}` : ''}` : row.reason || row.typeLabel; return ( { onSelectItem({ rowId: row.id, type: row.type, lead: row.originalLead, phoneRaw: row.phoneRaw || null, patientId: row.patientId, leadId: row.leadId, name: row.name, }); }} > {row.score != null ? (
{row.score.toFixed(1)}
) : ( {priority.label} )}
{row.direction === 'inbound' && ( )} {row.direction === 'outbound' && ( )}
{row.name} {subLine}
{row.phoneRaw ? ( onDialMissedCall?.(row.missedCallId!) : undefined} /> ) : ( No phone )} {row.source ? ( {formatSource(row.source)} ) : ( )} {sla.label}
); }}
{totalPages > 1 && (
Showing {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, filteredRows.length)} of {filteredRows.length}
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => ( ))}
)}
)}
); }; export type { WorklistLead };