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 `
${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
';
for (const item of items) {
const iname = item.item_name || item.itemName || item.name || 'Unknown';
const qty = item.quantity || item.qty || 1;
const price = item.unit_price || item.price;
itemsHtml += `- ${qty}x ${escapeHtml(iname)}${price ? ` — $${Number(price).toFixed(2)}` : ''}
`;
}
itemsHtml += '
';
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;
}