Files
kitchen-agent/public/js/abandoned-calls.js
odzugkoev 85cf732a61 done
2026-03-01 17:10:03 -05:00

500 lines
18 KiB
JavaScript

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 &middot; ${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">&#x1F5A8;</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">&#x1F5A8;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// 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;
}