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; }; type MissedCall = { id: string; createdAt: string; callDirection: string | null; callerNumber: { number: string; callingCode: string }[] | null; startedAt: string | null; leadId: string | null; disposition: string | null; callbackstatus: string | null; callsourcenumber: string | null; missedcallcount: number | null; callbackattemptedat: string | null; }; type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid'; interface WorklistPanelProps { missedCalls: MissedCall[]; followUps: WorklistFollowUp[]; leads: WorklistLead[]; loading: boolean; onSelectLead: (lead: WorklistLead) => void; selectedLeadId: 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; 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', }; const computeSla = (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' }; }; 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', }; return map[source] ?? source.replace(/_/g, ' '); }; 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: (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, originalLead: null, lastContactedAt: call.callbackattemptedat ?? call.startedAt ?? call.createdAt, contactAttempts: 0, source: 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'; 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: '', 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, 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, originalLead: lead, lastContactedAt: lead.lastContacted ?? null, contactAttempts: lead.contactAttempts ?? 0, source: lead.leadSource ?? lead.utmCampaign ?? null, lastDisposition: null, missedCallId: null, }); } // Remove rows without a phone number — agent can't act on them const actionableRows = rows.filter(r => r.phoneRaw); // 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, onSelectLead, selectedLeadId, onDialMissedCall }: WorklistPanelProps) => { const [tab, setTab] = useState('all'); const [search, setSearch] = useState(''); const [missedSubTab, setMissedSubTab] = useState('pending'); const [sortDescriptor, setSortDescriptor] = useState({ column: 'sla', direction: 'descending' }); 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'); 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': { const ta = new Date(a.lastContactedAt ?? a.createdAt).getTime(); const tb = new Date(b.lastContactedAt ?? b.createdAt).getTime(); 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').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 status sub-tabs */} {tab === 'missed' && (
{(['pending', 'attempted', 'completed', 'invalid'] as MissedSubTab[]).map(sub => ( ))}
)} {filteredRows.length === 0 ? (

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

) : (
{(row) => { const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL; const sla = computeSla(row.lastContactedAt ?? row.createdAt); const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId; // Sub-line: last interaction context const subLine = row.lastContactedAt ? `${formatTimeAgo(row.lastContactedAt)}${row.lastDisposition ? ` — ${formatDisposition(row.lastDisposition)}` : ''}` : row.reason || row.typeLabel; return ( { if (row.originalLead) onSelectLead(row.originalLead); }} > {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 };