This commit is contained in:
odzugkoev
2026-03-01 17:10:03 -05:00
parent 7e0887c62d
commit 85cf732a61
19 changed files with 2284 additions and 32 deletions

View File

@@ -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;
}
}

View 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 &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;
}

View File

@@ -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);
});

View File

@@ -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;