done
This commit is contained in:
499
public/js/abandoned-calls.js
Normal file
499
public/js/abandoned-calls.js
Normal file
@@ -0,0 +1,499 @@
|
||||
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 = '<div class="no-orders">No missed calls to display</div>';
|
||||
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 = `<span class="known-customer-badge">Known Customer · ${prevOrders} orders</span>`;
|
||||
}
|
||||
|
||||
let itemsHtml = '';
|
||||
const items = call.items || [];
|
||||
if (items.length > 0) {
|
||||
itemsHtml = '<div class="ac-items"><div class="ac-items-title">Items mentioned:</div>';
|
||||
for (const item of items) {
|
||||
const iname = item.item_name || item.itemName || item.name || 'Unknown';
|
||||
const qty = item.quantity || item.qty || 1;
|
||||
itemsHtml += `<div class="ac-item">${qty}x ${iname}</div>`;
|
||||
}
|
||||
if (call.partial_order_value || call.estimated_order_value) {
|
||||
const val = Number(call.partial_order_value || call.estimated_order_value);
|
||||
if (val > 0) itemsHtml += `<div class="ac-item-value">Est. value: $${val.toFixed(2)}</div>`;
|
||||
}
|
||||
itemsHtml += '</div>';
|
||||
}
|
||||
|
||||
let scriptHtml = '';
|
||||
if (call.llm_callback_script) {
|
||||
scriptHtml = `
|
||||
<div class="ac-callback-script">
|
||||
<div class="ac-script-label">Say this:</div>
|
||||
<div class="ac-script-text">${escapeHtml(call.llm_callback_script)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let summaryHtml = '';
|
||||
if (call.llm_summary) {
|
||||
summaryHtml = `<div class="ac-summary">${escapeHtml(call.llm_summary)}</div>`;
|
||||
}
|
||||
|
||||
const isDone = ['converted', 'dismissed', 'reached', 'no_answer'].includes(status);
|
||||
|
||||
let actionsHtml = '';
|
||||
if (!isDone) {
|
||||
actionsHtml = `
|
||||
<div class="ac-actions">
|
||||
<button class="btn btn-primary ac-action-btn" onclick="handleAction(${call.id}, 'call_back')">Call Back</button>
|
||||
<button class="btn btn-success ac-action-btn" onclick="handleAction(${call.id}, 'reached')">Reached</button>
|
||||
<button class="btn btn-warning ac-action-btn" onclick="handleAction(${call.id}, 'no_answer')">No Answer</button>
|
||||
<button class="btn btn-success ac-action-btn ac-action-converted" onclick="handleAction(${call.id}, 'converted')">Converted</button>
|
||||
<button class="btn btn-secondary ac-action-btn" onclick="handleAction(${call.id}, 'dismissed')">Dismiss</button>
|
||||
<button class="btn btn-secondary ac-action-btn" onclick="handleAction(${call.id}, 'deferred')">Defer</button>
|
||||
<button class="btn btn-secondary ac-action-btn ac-action-reprint" onclick="reprintReceipt(${call.id})" title="Reprint">🖨</button>
|
||||
</div>`;
|
||||
} else {
|
||||
actionsHtml = `
|
||||
<div class="ac-actions">
|
||||
<span class="ac-status-done">Status: ${status.replace('_', ' ')}</span>
|
||||
<button class="btn btn-secondary ac-action-btn ac-action-reprint" onclick="reprintReceipt(${call.id})" title="Reprint">🖨</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="order-card abandoned-call-card priority-${priority} ${isDone ? 'ac-done' : ''}" onclick="showDetails(${call.id}, event)">
|
||||
<div class="order-header ac-header">
|
||||
<div class="ac-header-left">
|
||||
<span class="ac-priority-badge badge-${priority}">${priority.toUpperCase()}</span>
|
||||
<span class="ac-time-ago">${timeAgo}</span>
|
||||
</div>
|
||||
<div class="ac-score">Score: ${score}/100</div>
|
||||
</div>
|
||||
|
||||
<div class="ac-caller-info">
|
||||
<div class="ac-phone">${escapeHtml(phone)}</div>
|
||||
${name ? `<div class="ac-name">${escapeHtml(name)}</div>` : ''}
|
||||
${knownBadge}
|
||||
</div>
|
||||
|
||||
<div class="ac-stage">${stageMsg}</div>
|
||||
<div class="ac-duration">Duration: ${duration}s</div>
|
||||
|
||||
${itemsHtml}
|
||||
${summaryHtml}
|
||||
${scriptHtml}
|
||||
${actionsHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = '<h4>Partial Order Items</h4><ul>';
|
||||
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 += `<li>${qty}x ${escapeHtml(iname)}${price ? ` — $${Number(price).toFixed(2)}` : ''}</li>`;
|
||||
}
|
||||
itemsHtml += '</ul>';
|
||||
if (call.partial_order_value) {
|
||||
itemsHtml += `<p><strong>Est. Value:</strong> $${Number(call.partial_order_value).toFixed(2)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="order-details">
|
||||
<div class="detail-section">
|
||||
<h3>Call Information</h3>
|
||||
<p><strong>ID:</strong> ${call.id}</p>
|
||||
<p><strong>Time:</strong> ${timeStr}</p>
|
||||
<p><strong>Duration:</strong> ${call.duration_seconds || 0}s</p>
|
||||
<p><strong>Priority:</strong> <span class="ac-priority-badge badge-${priority}">${priority.toUpperCase()}</span></p>
|
||||
<p><strong>Score:</strong> ${score}/100</p>
|
||||
<p><strong>Stage:</strong> ${call.abandonment_stage || 'N/A'}</p>
|
||||
<p><strong>Intent:</strong> ${call.detected_intent || 'N/A'}</p>
|
||||
<p><strong>Status:</strong> ${status}</p>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>Caller</h3>
|
||||
<p><strong>Phone:</strong> ${escapeHtml(call.caller_phone_normalized || call.caller_phone || 'Unknown')}</p>
|
||||
${call.caller_name ? `<p><strong>Name:</strong> ${escapeHtml(call.caller_name)}</p>` : ''}
|
||||
<p><strong>Known Customer:</strong> ${call.is_known_customer ? 'Yes' : 'No'}</p>
|
||||
${call.is_known_customer ? `<p><strong>Previous Orders:</strong> ${call.previous_order_count || 0}</p>` : ''}
|
||||
</div>
|
||||
|
||||
${itemsHtml ? `<div class="detail-section">${itemsHtml}</div>` : ''}
|
||||
|
||||
${call.caller_speech_text ? `
|
||||
<div class="detail-section">
|
||||
<h3>Caller Speech</h3>
|
||||
<p>${escapeHtml(call.caller_speech_text)}</p>
|
||||
</div>` : ''}
|
||||
|
||||
${call.llm_summary ? `
|
||||
<div class="detail-section">
|
||||
<h3>AI Analysis</h3>
|
||||
<p><strong>Summary:</strong> ${escapeHtml(call.llm_summary)}</p>
|
||||
${call.llm_sentiment ? `<p><strong>Sentiment:</strong> ${call.llm_sentiment}</p>` : ''}
|
||||
${call.llm_hesitancy_analysis ? `<p><strong>Hesitancy:</strong> ${escapeHtml(call.llm_hesitancy_analysis)}</p>` : ''}
|
||||
${call.llm_recovery_suggestion ? `<p><strong>Recovery:</strong> ${escapeHtml(call.llm_recovery_suggestion)}</p>` : ''}
|
||||
</div>` : ''}
|
||||
|
||||
${call.llm_callback_script ? `
|
||||
<div class="detail-section">
|
||||
<h3>Callback Script</h3>
|
||||
<div class="ac-callback-script">
|
||||
<div class="ac-script-text">${escapeHtml(call.llm_callback_script)}</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
${call.score_breakdown ? `
|
||||
<div class="detail-section">
|
||||
<h3>Score Breakdown</h3>
|
||||
<pre style="font-size:12px;overflow:auto;">${typeof call.score_breakdown === 'string' ? call.score_breakdown : JSON.stringify(call.score_breakdown, null, 2)}</pre>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const isDone = ['converted', 'dismissed', 'reached', 'no_answer'].includes(status);
|
||||
if (!isDone) {
|
||||
actions.innerHTML = `
|
||||
<button class="btn btn-primary" onclick="handleActionFromModal('call_back')">Call Back</button>
|
||||
<button class="btn btn-success" onclick="handleActionFromModal('reached')">Reached</button>
|
||||
<button class="btn btn-warning" onclick="handleActionFromModal('no_answer')">No Answer</button>
|
||||
<button class="btn btn-success" onclick="handleActionFromModal('converted')">Converted</button>
|
||||
<button class="btn btn-secondary" onclick="handleActionFromModal('dismissed')">Dismiss</button>
|
||||
<button class="btn btn-secondary" onclick="handleActionFromModal('deferred')">Defer</button>
|
||||
<button class="btn btn-secondary" onclick="reprintReceipt(${call.id})">Reprint</button>
|
||||
<button class="btn btn-secondary" onclick="closeDetailsModal()">Close</button>
|
||||
`;
|
||||
} else {
|
||||
actions.innerHTML = `
|
||||
<span class="ac-status-done">Status: ${status.replace('_', ' ')}</span>
|
||||
<button class="btn btn-secondary" onclick="reprintReceipt(${call.id})">Reprint</button>
|
||||
<button class="btn btn-secondary" onclick="closeDetailsModal()">Close</button>
|
||||
`;
|
||||
}
|
||||
|
||||
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, '>').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;
|
||||
}
|
||||
Reference in New Issue
Block a user