done
This commit is contained in:
@@ -1602,3 +1602,388 @@ button, a, input, select, textarea {
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Abandoned Calls ===== */
|
||||
|
||||
/* Dashboard notification badge */
|
||||
.abandoned-calls-link {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.abandoned-calls-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.abandoned-calls-badge {
|
||||
background: #dc3545;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
border-radius: 10px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.abandoned-calls-link.pulse {
|
||||
animation: badgePulse 0.6s ease-in-out 3;
|
||||
}
|
||||
|
||||
@keyframes badgePulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
|
||||
/* Back link in header */
|
||||
.header-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.15);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
background: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
/* Abandoned call cards */
|
||||
.abandoned-call-card {
|
||||
border-left-width: 5px;
|
||||
border-left-style: solid;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.abandoned-call-card:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.abandoned-call-card.priority-critical { border-left-color: #dc3545; }
|
||||
.abandoned-call-card.priority-high { border-left-color: #fd7e14; }
|
||||
.abandoned-call-card.priority-medium { border-left-color: #ffc107; }
|
||||
.abandoned-call-card.priority-low { border-left-color: #adb5bd; }
|
||||
.abandoned-call-card.priority-none { border-left-color: #dee2e6; }
|
||||
|
||||
.abandoned-call-card.ac-done {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
/* Card header */
|
||||
.ac-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ac-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ac-priority-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge-critical { background: #dc3545; color: #fff; }
|
||||
.badge-high { background: #fd7e14; color: #fff; }
|
||||
.badge-medium { background: #ffc107; color: #333; }
|
||||
.badge-low { background: #e9ecef; color: #666; }
|
||||
.badge-none { background: #f8f9fa; color: #999; }
|
||||
|
||||
.ac-time-ago {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.ac-score {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Caller info */
|
||||
.ac-caller-info {
|
||||
margin: 10px 0 6px;
|
||||
}
|
||||
|
||||
.ac-phone {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.ac-name {
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.known-customer-badge {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
padding: 2px 8px;
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Stage and duration */
|
||||
.ac-stage {
|
||||
font-size: 14px;
|
||||
color: #444;
|
||||
margin: 8px 0 2px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ac-duration {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Items */
|
||||
.ac-items {
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.ac-items-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ac-item {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.ac-item-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #28a745;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* LLM summary */
|
||||
.ac-summary {
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
font-style: italic;
|
||||
margin: 6px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Callback script */
|
||||
.ac-callback-script {
|
||||
background: #fff8e1;
|
||||
border-left: 3px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.ac-script-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: #e65100;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.ac-script-text {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.ac-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.ac-action-btn {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.ac-action-converted {
|
||||
border: 2px solid #28a745;
|
||||
}
|
||||
|
||||
.ac-action-reprint {
|
||||
flex: 0 0 44px;
|
||||
padding: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ac-status-done {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
text-transform: capitalize;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* Stats for abandoned calls page */
|
||||
.stat-abandoned-pending {
|
||||
border-left: 4px solid #dc3545;
|
||||
}
|
||||
|
||||
.stat-converted {
|
||||
border-left: 4px solid #28a745;
|
||||
}
|
||||
|
||||
/* Controls layout */
|
||||
.controls-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: #ccc;
|
||||
border-radius: 12px;
|
||||
transition: background 0.25s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-slider::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.25s;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background: #667eea;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Filter button colors for abandoned calls page */
|
||||
.filter-critical { color: #dc3545; }
|
||||
.filter-critical.active { background: #dc3545; color: #fff; }
|
||||
.filter-high { color: #fd7e14; }
|
||||
.filter-high.active { background: #fd7e14; color: #fff; }
|
||||
.filter-medium { color: #856404; }
|
||||
.filter-medium.active { background: #ffc107; color: #333; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.ac-phone {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ac-action-btn {
|
||||
font-size: 12px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.ac-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ac-action-btn {
|
||||
min-height: 40px;
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.abandoned-calls-link span:not(.abandoned-calls-badge):not(.abandoned-calls-icon) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -84,18 +84,50 @@ const audioNotification = {
|
||||
}
|
||||
};
|
||||
|
||||
// Abandoned call badge tracking
|
||||
let lastAbandonedCallCount = 0;
|
||||
|
||||
function refreshAbandonedCallCount() {
|
||||
fetch('/api/abandoned-calls/pending-count')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.error) return;
|
||||
const count = data.count || 0;
|
||||
const link = document.getElementById('abandonedCallsLink');
|
||||
const badge = document.getElementById('abandonedCallsBadge');
|
||||
if (!link || !badge) return;
|
||||
|
||||
if (count > 0) {
|
||||
badge.style.display = '';
|
||||
badge.textContent = count;
|
||||
if (count > lastAbandonedCallCount && lastAbandonedCallCount >= 0) {
|
||||
link.classList.add('pulse');
|
||||
setTimeout(() => link.classList.remove('pulse'), 2000);
|
||||
if (!isFirstLoad) {
|
||||
audioNotification.playNewOrderSound();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
lastAbandonedCallCount = count;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Initialize dashboard
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setupFilterButtons();
|
||||
audioNotification.init();
|
||||
refreshOrders();
|
||||
refreshAbandonedCallCount();
|
||||
|
||||
// Set up auto-refresh
|
||||
setInterval(refreshOrders, config.dashboardRefreshInterval || 10000);
|
||||
setInterval(refreshAbandonedCallCount, 30000);
|
||||
|
||||
// Initialize connection monitoring
|
||||
checkConnectionStatus();
|
||||
// Check connection status every 15 seconds
|
||||
setInterval(checkConnectionStatus, 15000);
|
||||
});
|
||||
|
||||
|
||||
@@ -206,6 +206,7 @@ function openAddPrinterModal() {
|
||||
document.getElementById('qr_code_enabled').checked = true;
|
||||
document.getElementById('is_enabled').checked = true;
|
||||
document.getElementById('is_default').checked = false;
|
||||
document.getElementById('print_abandoned_calls').checked = true;
|
||||
|
||||
showModal('printerConfigModal');
|
||||
switchPrinterModalTab('connection');
|
||||
@@ -268,6 +269,7 @@ async function editPrinter(id) {
|
||||
document.getElementById('qr_code_enabled').checked = printer.qr_code_enabled !== false;
|
||||
document.getElementById('is_default').checked = printer.is_default || false;
|
||||
document.getElementById('is_enabled').checked = printer.is_enabled !== false;
|
||||
document.getElementById('print_abandoned_calls').checked = printer.print_abandoned_calls !== false;
|
||||
|
||||
showModal('printerConfigModal');
|
||||
switchPrinterModalTab('connection');
|
||||
@@ -293,7 +295,7 @@ async function savePrinterConfig() {
|
||||
if (key === 'id' && !value) continue; // Skip empty id
|
||||
|
||||
// Handle checkboxes
|
||||
if (['show_customer_info', 'show_order_items', 'show_prices', 'show_timestamps', 'qr_code_enabled', 'is_default', 'is_enabled'].includes(key)) {
|
||||
if (['show_customer_info', 'show_order_items', 'show_prices', 'show_timestamps', 'qr_code_enabled', 'is_default', 'is_enabled', 'print_abandoned_calls'].includes(key)) {
|
||||
config[key] = document.getElementById(key).checked;
|
||||
} else if (key === 'paper_width' || key === 'qr_code_size' || key === 'logo_max_width_dots') {
|
||||
const val = parseInt(value, 10);
|
||||
@@ -304,7 +306,7 @@ async function savePrinterConfig() {
|
||||
}
|
||||
|
||||
// Ensure checkbox fields are always present in payload (unchecked boxes are omitted from FormData by default)
|
||||
['show_customer_info', 'show_order_items', 'show_prices', 'show_timestamps', 'qr_code_enabled', 'is_default', 'is_enabled'].forEach((key) => {
|
||||
['show_customer_info', 'show_order_items', 'show_prices', 'show_timestamps', 'qr_code_enabled', 'is_default', 'is_enabled', 'print_abandoned_calls'].forEach((key) => {
|
||||
const el = document.getElementById(key);
|
||||
if (el) {
|
||||
config[key] = !!el.checked;
|
||||
|
||||
Reference in New Issue
Block a user