let currentFilter = 'all'; let showProcessed = false; let previousCallIds = new Set(); let isFirstLoad = true; let currentDetailCall = null; const PROCESSED_STATUSES = new Set(['converted', 'dismissed', 'reached', 'no_answer', 'attempted']); const connectionStatus = { local: { status: 'checking', lastCheck: null, consecutiveFailures: 0 }, api: { status: 'checking', lastCheck: null, consecutiveFailures: 0, responseTime: null } }; const audioNotification = { sound: null, enabled: true, init: function() { fetch('/api/notification-settings') .then(r => r.json()) .then(data => { if (!data.error) { this.enabled = data.soundNotificationsEnabled !== 'false'; const soundPath = data.newOrderSoundPath || '/public/sounds/new-order-notification.mp3'; this.sound = new Audio(soundPath); if (data.soundVolume) this.sound.volume = parseInt(data.soundVolume, 10) / 100; } }) .catch(() => { this.sound = new Audio('/public/sounds/new-order-notification.mp3'); }); }, play: function() { if (this.enabled && this.sound) { this.sound.currentTime = 0; this.sound.play().catch(() => {}); } } }; document.addEventListener('DOMContentLoaded', function() { setupFilterButtons(); setupShowProcessedToggle(); audioNotification.init(); refreshAbandonedCalls(); setInterval(refreshAbandonedCalls, config.refreshInterval || 15000); checkConnectionStatus(); setInterval(checkConnectionStatus, 15000); }); function setupShowProcessedToggle() { const checkbox = document.getElementById('showProcessedCheckbox'); if (checkbox) { checkbox.checked = showProcessed; checkbox.addEventListener('change', function() { showProcessed = this.checked; refreshAbandonedCalls(); }); } } function setupFilterButtons() { document.querySelectorAll('.filter-btn').forEach(button => { button.addEventListener('click', function() { document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); this.classList.add('active'); currentFilter = this.dataset.filter; refreshAbandonedCalls(); }); }); } function refreshAbandonedCalls() { const syncButton = document.getElementById('syncButton'); const syncText = syncButton ? syncButton.querySelector('.sync-text') : null; if (syncButton) { syncButton.classList.add('loading'); syncButton.disabled = true; } if (syncText) syncText.textContent = 'Loading...'; let url; if (currentFilter === 'queued') { url = '/api/abandoned-calls/callback-queue?limit=50'; } else if (currentFilter === 'all') { url = '/api/abandoned-calls?limit=100'; } else { url = '/api/abandoned-calls?limit=100&priority=' + currentFilter; } fetch(url) .then(r => r.json()) .then(data => { if (!data.error) { let calls = data.queue || data.calls || []; if (!showProcessed) { calls = calls.filter(c => !PROCESSED_STATUSES.has(c.callback_status)); } updateCards(calls); refreshStats(); } }) .catch(err => console.error('Failed to load abandoned calls:', err)) .finally(() => { if (syncButton) { syncButton.classList.remove('loading'); syncButton.disabled = false; } if (syncText) syncText.textContent = 'Refresh'; }); } function refreshStats() { fetch('/api/abandoned-calls/pending-count') .then(r => r.json()) .then(data => { if (!data.error) { const el = document.getElementById('stat-pending'); if (el) el.textContent = data.count || 0; } }) .catch(() => {}); } function updateCards(calls) { const container = document.getElementById('abandonedCallsContainer'); if (!calls || calls.length === 0) { container.innerHTML = '
No missed calls to display
'; return; } if (!isFirstLoad) { const currentIds = new Set(calls.map(c => c.id)); for (const call of calls) { if (!previousCallIds.has(call.id)) { const p = call.callback_priority; if (p === 'critical' || p === 'high') { audioNotification.play(); break; } } } previousCallIds = currentIds; } else { previousCallIds = new Set(calls.map(c => c.id)); isFirstLoad = false; } container.innerHTML = calls.map(call => createCard(call)).join(''); } function createCard(call) { const priority = call.callback_priority || 'low'; const score = Number(call.callback_score) || 0; const stage = call.abandonment_stage || 'unknown'; const status = call.callback_status || 'queued'; const phone = call.caller_phone_normalized || call.caller_phone || 'Unknown'; const name = call.caller_name || ''; const isKnown = call.is_known_customer; const prevOrders = Number(call.previous_order_count) || 0; const duration = Number(call.duration_seconds) || 0; const timeAgo = getTimeAgo(call.call_started_at || call.created_at); const stageMessages = { ring_only: 'Called but hung up before AI answered.', greeting_hangup: 'Heard AI greeting, hung up immediately.', silent_post_greeting: 'Listened but never spoke — likely uncomfortable with AI.', minimal_speech: 'Said very little then hung up.', pre_intent: 'Spoke briefly but intent unclear.', intent_identified: 'Wanted to order/book but hung up.', partial_order: 'Started an order then hung up!', partial_appointment: 'Was booking an appointment then hung up!', pre_confirmation: 'Order nearly complete — hung up before confirming.', system_failure: 'System error caused disconnection.' }; const stageMsg = stageMessages[stage] || 'Call ended unexpectedly.'; let knownBadge = ''; if (isKnown) { knownBadge = `Known Customer · ${prevOrders} orders`; } let itemsHtml = ''; const items = call.items || []; if (items.length > 0) { itemsHtml = '
Items mentioned:
'; for (const item of items) { const iname = item.item_name || item.itemName || item.name || 'Unknown'; const qty = item.quantity || item.qty || 1; itemsHtml += `
${qty}x ${iname}
`; } if (call.partial_order_value || call.estimated_order_value) { const val = Number(call.partial_order_value || call.estimated_order_value); if (val > 0) itemsHtml += `
Est. value: $${val.toFixed(2)}
`; } itemsHtml += '
'; } let scriptHtml = ''; if (call.llm_callback_script) { scriptHtml = `
Say this:
${escapeHtml(call.llm_callback_script)}
`; } let summaryHtml = ''; if (call.llm_summary) { summaryHtml = `
${escapeHtml(call.llm_summary)}
`; } const isDone = ['converted', 'dismissed', 'reached', 'no_answer'].includes(status); let actionsHtml = ''; if (!isDone) { actionsHtml = `
`; } else { actionsHtml = `
Status: ${status.replace('_', ' ')}
`; } return `
${priority.toUpperCase()} ${timeAgo}
Score: ${score}/100
${escapeHtml(phone)}
${name ? `
${escapeHtml(name)}
` : ''} ${knownBadge}
${stageMsg}
Duration: ${duration}s
${itemsHtml} ${summaryHtml} ${scriptHtml} ${actionsHtml}
`; } function handleAction(callId, action) { event.stopPropagation(); fetch(`/api/abandoned-calls/${callId}/action`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }) }) .then(r => r.json()) .then(data => { if (!data.error) { const labels = { call_back: 'Marked as called back', reached: 'Marked as reached', no_answer: 'Marked as no answer', converted: 'Marked as converted!', dismissed: 'Dismissed', deferred: 'Deferred for later' }; showToast(labels[action] || 'Updated', 'success'); refreshAbandonedCalls(); } else { showToast(data.message || 'Action failed', 'error'); } }) .catch(() => showToast('Action failed', 'error')); } function reprintReceipt(callId) { event.stopPropagation(); fetch(`/api/abandoned-calls/${callId}/reprint`, { method: 'POST' }) .then(r => r.json()) .then(data => { if (!data.error) { showToast(data.message || 'Sent to printer', 'success'); } else { showToast(data.message || 'Print failed', 'error'); } }) .catch(() => showToast('Print failed', 'error')); } function showDetails(callId, event) { if (event && (event.target.tagName === 'BUTTON' || event.target.closest('button'))) return; fetch('/api/abandoned-calls?limit=200') .then(r => r.json()) .then(data => { const calls = data.calls || data.queue || []; const call = calls.find(c => c.id === callId); if (call) displayDetails(call); }) .catch(() => {}); } function displayDetails(call) { currentDetailCall = call; const content = document.getElementById('callDetailsContent'); const actions = document.getElementById('detailModalActions'); const priority = call.callback_priority || 'low'; const score = Number(call.callback_score) || 0; const status = call.callback_status || 'queued'; const callTime = call.call_started_at ? new Date(Number(call.call_started_at) * 1000) : null; const timeStr = callTime ? callTime.toLocaleString('en-US') : 'N/A'; let itemsHtml = ''; const items = call.items || []; if (items.length > 0) { itemsHtml = '

Partial Order Items

'; if (call.partial_order_value) { itemsHtml += `

Est. Value: $${Number(call.partial_order_value).toFixed(2)}

`; } } content.innerHTML = `

Call Information

ID: ${call.id}

Time: ${timeStr}

Duration: ${call.duration_seconds || 0}s

Priority: ${priority.toUpperCase()}

Score: ${score}/100

Stage: ${call.abandonment_stage || 'N/A'}

Intent: ${call.detected_intent || 'N/A'}

Status: ${status}

Caller

Phone: ${escapeHtml(call.caller_phone_normalized || call.caller_phone || 'Unknown')}

${call.caller_name ? `

Name: ${escapeHtml(call.caller_name)}

` : ''}

Known Customer: ${call.is_known_customer ? 'Yes' : 'No'}

${call.is_known_customer ? `

Previous Orders: ${call.previous_order_count || 0}

` : ''}
${itemsHtml ? `
${itemsHtml}
` : ''} ${call.caller_speech_text ? `

Caller Speech

${escapeHtml(call.caller_speech_text)}

` : ''} ${call.llm_summary ? `

AI Analysis

Summary: ${escapeHtml(call.llm_summary)}

${call.llm_sentiment ? `

Sentiment: ${call.llm_sentiment}

` : ''} ${call.llm_hesitancy_analysis ? `

Hesitancy: ${escapeHtml(call.llm_hesitancy_analysis)}

` : ''} ${call.llm_recovery_suggestion ? `

Recovery: ${escapeHtml(call.llm_recovery_suggestion)}

` : ''}
` : ''} ${call.llm_callback_script ? `

Callback Script

${escapeHtml(call.llm_callback_script)}
` : ''} ${call.score_breakdown ? `

Score Breakdown

${typeof call.score_breakdown === 'string' ? call.score_breakdown : JSON.stringify(call.score_breakdown, null, 2)}
` : ''}
`; const isDone = ['converted', 'dismissed', 'reached', 'no_answer'].includes(status); if (!isDone) { actions.innerHTML = ` `; } else { actions.innerHTML = ` Status: ${status.replace('_', ' ')} `; } document.getElementById('detailsModal').classList.add('visible'); } function handleActionFromModal(action) { if (!currentDetailCall) return; closeDetailsModal(); handleAction(currentDetailCall.id, action); } function closeDetailsModal() { document.getElementById('detailsModal').classList.remove('visible'); currentDetailCall = null; } window.addEventListener('click', function(e) { if (e.target === document.getElementById('detailsModal')) closeDetailsModal(); }); document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeDetailsModal(); }); function manualRefresh() { refreshAbandonedCalls(); } // Utility function getTimeAgo(timestamp) { if (!timestamp) return ''; const ts = Number(timestamp); const now = Math.floor(Date.now() / 1000); const diff = now - ts; if (diff < 60) return 'just now'; if (diff < 3600) return Math.floor(diff / 60) + ' min ago'; if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; return Math.floor(diff / 86400) + 'd ago'; } function escapeHtml(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // Connection monitoring (same pattern as dashboard) function checkConnectionStatus() { fetch('/api/health/local') .then(r => r.ok ? r.json() : Promise.reject()) .then(() => { connectionStatus.local.consecutiveFailures = 0; updateConnectionUI('local', 'online', 'Connected'); }) .catch(() => { connectionStatus.local.consecutiveFailures++; if (connectionStatus.local.consecutiveFailures >= 2) updateConnectionUI('local', 'offline', 'Disconnected'); }); fetch('/api/health/external') .then(r => r.ok ? r.json() : Promise.reject()) .then(data => { if (data.status === 'online') { connectionStatus.api.consecutiveFailures = 0; updateConnectionUI('api', 'online', data.responseTime ? data.responseTime + 'ms' : 'Connected'); } else if (data.status === 'unconfigured') { updateConnectionUI('api', 'unconfigured', 'Not Configured'); } else { connectionStatus.api.consecutiveFailures++; if (connectionStatus.api.consecutiveFailures >= 2) updateConnectionUI('api', 'offline', data.message || 'Disconnected'); } }) .catch(() => { connectionStatus.api.consecutiveFailures++; if (connectionStatus.api.consecutiveFailures >= 2) updateConnectionUI('api', 'offline', 'Check Failed'); }); } function updateConnectionUI(type, status, label) { const elId = type === 'local' ? 'localConnectionStatus' : 'apiConnectionStatus'; const labelId = type === 'local' ? 'localStatusLabel' : 'apiStatusLabel'; const item = document.getElementById(elId); const labelEl = document.getElementById(labelId); if (!item || !labelEl) return; const indicator = item.querySelector('.status-indicator'); indicator.classList.remove('status-online', 'status-offline', 'status-checking', 'status-unconfigured'); indicator.classList.add('status-' + status); labelEl.textContent = label; }