// Dashboard functionality let currentFilter = 'all'; let currentOrderIdForCancel = null; let pendingStatusChange = { orderId: null, newStatus: null, currentStatus: null, orderData: null }; // Track orders for notification detection let previousOrderIds = new Set(); let previousCanceledOrderIds = new Set(); let isFirstLoad = true; // Connection status tracking const connectionStatus = { local: { status: 'checking', lastCheck: null, consecutiveFailures: 0 }, api: { status: 'checking', lastCheck: null, consecutiveFailures: 0, responseTime: null } }; // Audio notification system const audioNotification = { newOrderSound: null, canceledOrderSound: null, enabled: true, init: function() { // Load notification settings from server fetch('/api/notification-settings') .then(response => response.json()) .then(data => { if (!data.error) { this.enabled = data.soundNotificationsEnabled !== 'false'; const newOrderPath = data.newOrderSoundPath || '/public/sounds/new-order-notification.mp3'; const canceledOrderPath = data.canceledOrderSoundPath || '/public/sounds/new-order-notification.mp3'; // Preload audio files this.newOrderSound = new Audio(newOrderPath); this.canceledOrderSound = new Audio(canceledOrderPath); // Set volume if specified if (data.soundVolume) { const volume = parseInt(data.soundVolume, 10) / 100; this.newOrderSound.volume = volume; this.canceledOrderSound.volume = volume; } } }) .catch(error => { console.warn('Failed to load notification settings:', error); // Use default sound this.newOrderSound = new Audio('/public/sounds/new-order-notification.mp3'); this.canceledOrderSound = new Audio('/public/sounds/new-order-notification.mp3'); }); }, playNewOrderSound: function() { if (this.enabled && this.newOrderSound) { this.newOrderSound.currentTime = 0; this.newOrderSound.play().catch(error => { console.warn('Failed to play new order sound:', error); }); } }, playCanceledOrderSound: function() { if (this.enabled && this.canceledOrderSound) { this.canceledOrderSound.currentTime = 0; this.canceledOrderSound.play().catch(error => { console.warn('Failed to play canceled order sound:', error); }); } } }; // Initialize dashboard document.addEventListener('DOMContentLoaded', function() { setupFilterButtons(); audioNotification.init(); refreshOrders(); // Set up auto-refresh setInterval(refreshOrders, config.dashboardRefreshInterval || 10000); // Initialize connection monitoring checkConnectionStatus(); // Check connection status every 15 seconds setInterval(checkConnectionStatus, 15000); }); function setupFilterButtons() { const filterButtons = document.querySelectorAll('.filter-btn'); filterButtons.forEach(button => { button.addEventListener('click', function() { // Remove active class from all buttons filterButtons.forEach(btn => btn.classList.remove('active')); // Add active class to clicked button this.classList.add('active'); // Update filter and refresh currentFilter = this.dataset.filter; refreshOrders(); }); }); } function refreshOrders() { 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 = 'Syncing...'; } } const statusParam = currentFilter === 'all' ? '' : currentFilter; const url = '/api/orders' + (statusParam ? '?status=' + statusParam : ''); fetch(url) .then(response => response.json()) .then(data => { if (!data.error) { updateOrderCards(data.orders); updateStats(data.stats); // Connection successful - reset local connection failure counter if (connectionStatus.local.consecutiveFailures > 0) { connectionStatus.local.consecutiveFailures = 0; connectionStatus.local.status = 'online'; updateConnectionUI('local', 'online', 'Connected'); } } }) .catch(error => { console.error('Failed to refresh orders:', error); // Mark local connection as potentially offline connectionStatus.local.consecutiveFailures++; if (connectionStatus.local.consecutiveFailures >= 2) { connectionStatus.local.status = 'offline'; updateConnectionUI('local', 'offline', 'Disconnected'); } }) .finally(() => { if (syncButton) { syncButton.classList.remove('loading'); syncButton.disabled = false; if (syncText) { syncText.textContent = 'Sync Now'; } } }); } function updateStats(stats) { if (!stats) return; const totalEl = document.getElementById('stat-total'); const newEl = document.getElementById('stat-new'); const preparingEl = document.getElementById('stat-preparing'); const readyEl = document.getElementById('stat-ready'); if (totalEl) totalEl.textContent = stats.total || 0; if (newEl) newEl.textContent = stats.new || 0; if (preparingEl) preparingEl.textContent = stats.preparing || 0; if (readyEl) readyEl.textContent = stats.ready || 0; } function updateOrderCards(orders) { const container = document.getElementById('ordersContainer'); if (!orders || orders.length === 0) { container.innerHTML = '
No orders to display
'; return; } // Detect new orders and canceled orders for sound notifications if (!isFirstLoad) { const currentOrderIds = new Set(); const currentCanceledOrderIds = new Set(); orders.forEach(order => { const orderId = order.id; const status = order.localStatus || order.status || 'new'; currentOrderIds.add(orderId); if (status === 'canceled') { currentCanceledOrderIds.add(orderId); } // Check for new orders (not in previous set) if (!previousOrderIds.has(orderId) && status === 'new') { console.log('New order detected:', orderId); audioNotification.playNewOrderSound(); } // Check for newly canceled orders if (!previousCanceledOrderIds.has(orderId) && status === 'canceled') { console.log('Order canceled:', orderId); audioNotification.playCanceledOrderSound(); } }); // Update tracking sets previousOrderIds = currentOrderIds; previousCanceledOrderIds = currentCanceledOrderIds; } else { // On first load, just populate the tracking sets without playing sounds orders.forEach(order => { previousOrderIds.add(order.id); const status = order.localStatus || order.status || 'new'; if (status === 'canceled') { previousCanceledOrderIds.add(order.id); } }); isFirstLoad = false; } container.innerHTML = orders.map(order => createOrderCard(order)).join(''); } function createOrderCard(order) { const status = order.localStatus || order.status || 'new'; const statusBadge = getStatusBadge(status); const orderType = order.order.type || 'pickup'; const typeIcon = orderType === 'delivery' ? '🚚' : '🛍️'; // Determine which actions to show based on status let actions = ''; if (status === 'new') { actions = ` `; } else if (status === 'preparing') { actions = ` `; } else if (status === 'ready') { actions = ` `; } // Add cancel and reprint buttons for non-completed orders if (status !== 'canceled' && status !== 'completed') { actions += ` `; } actions += ` `; // Build detailed items list with addons and excludes let itemsDetailedHtml = ''; if (order.order.items && order.order.items.length > 0) { // Sort items alphabetically by name for better kitchen organization const sortedItems = [...order.order.items].sort((a, b) => { const nameA = (a.itemName || a.name || 'Item').toLowerCase(); const nameB = (b.itemName || b.name || 'Item').toLowerCase(); return nameA.localeCompare(nameB); }); itemsDetailedHtml = sortedItems.map(item => { let modifiersHtml = ''; // Add addons if (item.addons && item.addons.length > 0) { const addonsList = item.addons.map(a => a.name || a).join(', '); modifiersHtml += `
+ Add: ${addonsList}
`; } // Add excludes if (item.exclude && item.exclude.length > 0) { const excludeList = item.exclude.map(e => e.name || e).join(', '); modifiersHtml += `
− NO: ${excludeList}
`; } return `
${item.qty}x ${item.itemName || item.name || 'Item'}
${modifiersHtml}
`; }).join(''); } // Food allergy warning (prominent) let allergyWarning = ''; if (order.order.foodAllergy) { const allergyNotes = order.order.foodAllergyNotes || 'Customer has food allergies'; allergyWarning = `
⚠️
FOOD ALLERGY ALERT
${allergyNotes}
`; } // Special instructions let specialInstructionsHtml = ''; if (order.order.specialInstructions) { specialInstructionsHtml = `
📝 Special Instructions:
${order.order.specialInstructions}
`; } return `
#${order.id}
${statusBadge}
${allergyWarning}
Time: ${formatTime(order.createdAt)}
Customer: ${order.customer.name || 'N/A'}
Type: ${typeIcon}${orderType}
${order.order.deliveryAddress ? `
Address:${order.order.deliveryAddress}
` : ''}
Order Items:
${itemsDetailedHtml}
${specialInstructionsHtml}
Total: ${formatCurrency(order.totalAmount)}
${actions}
`; } function getStatusBadge(status) { const badges = { 'new': 'New', 'preparing': 'Preparing', 'ready': 'Ready', 'completed': 'Completed', 'canceled': 'Canceled' }; return badges[status] || status; } function changeStatus(orderId, newStatus) { // Fetch the order data first to show in the confirmation modal fetch(`/api/orders/${orderId}`) .then(response => response.json()) .then(data => { if (!data.error && data.order) { openStatusChangeModal(orderId, newStatus, data.order); } else { showToast('Failed to fetch order details', 'error'); } }) .catch(error => { console.error('Error fetching order:', error); showToast('Failed to fetch order details', 'error'); }); } function openStatusChangeModal(orderId, newStatus, orderData) { const currentStatus = orderData.localStatus || orderData.status || 'new'; // Store pending status change pendingStatusChange = { orderId: orderId, newStatus: newStatus, currentStatus: currentStatus, orderData: orderData }; // Get status information const statusInfo = getStatusInfo(newStatus); // Update modal icon const iconEl = document.getElementById('statusModalIcon'); iconEl.textContent = statusInfo.icon; iconEl.className = 'status-modal-icon ' + newStatus; // Update modal title document.getElementById('statusModalTitle').textContent = statusInfo.title; // Update order details document.getElementById('statusModalOrderId').textContent = orderId; document.getElementById('statusModalCustomer').textContent = orderData.customer.name || 'N/A'; // Update status badges const fromBadge = document.getElementById('statusModalFrom'); const toBadge = document.getElementById('statusModalTo'); fromBadge.textContent = getStatusBadge(currentStatus); fromBadge.className = 'status-badge badge-' + currentStatus; toBadge.textContent = getStatusBadge(newStatus); toBadge.className = 'status-badge badge-' + newStatus; // Update message document.getElementById('statusModalMessage').textContent = statusInfo.message; // Update confirm button const confirmBtn = document.getElementById('statusConfirmBtn'); confirmBtn.textContent = statusInfo.buttonText; // Show modal document.getElementById('statusChangeModal').classList.add('visible'); } function getStatusInfo(status) { const statusMap = { 'preparing': { icon: '👨‍🍳', title: 'Start Preparing Order?', message: 'This will mark the order as being prepared in the kitchen.', buttonText: 'Start Preparing' }, 'ready': { icon: '✅', title: 'Mark Order as Ready?', message: 'This will notify that the order is ready for pickup or delivery.', buttonText: 'Mark Ready' }, 'completed': { icon: '🎉', title: 'Complete Order?', message: 'This will mark the order as completed and it will be archived.', buttonText: 'Complete Order' } }; return statusMap[status] || { icon: '📋', title: 'Change Order Status?', message: 'This will update the order status.', buttonText: 'Confirm' }; } function closeStatusChangeModal() { document.getElementById('statusChangeModal').classList.remove('visible'); pendingStatusChange = { orderId: null, newStatus: null, currentStatus: null, orderData: null }; } function confirmStatusChange() { if (!pendingStatusChange.orderId || !pendingStatusChange.newStatus) { return; } const orderId = pendingStatusChange.orderId; const newStatus = pendingStatusChange.newStatus; // Close modal immediately for better UX closeStatusChangeModal(); // Make the API call fetch(`/api/orders/${orderId}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: newStatus }) }) .then(response => response.json()) .then(data => { if (!data.error) { showToast(`Order #${orderId} updated to ${newStatus}`, 'success'); refreshOrders(); } else { showToast(data.message || 'Failed to update order', 'error'); } }) .catch(error => { console.error('Error updating order:', error); showToast('Failed to update order', 'error'); }); } function openCancelModal(orderId) { currentOrderIdForCancel = orderId; document.getElementById('cancelReason').value = ''; document.getElementById('cancelModal').classList.add('visible'); } function closeCancelModal() { currentOrderIdForCancel = null; document.getElementById('cancelModal').classList.remove('visible'); } function confirmCancel() { if (!currentOrderIdForCancel) return; const reason = document.getElementById('cancelReason').value; fetch(`/api/orders/${currentOrderIdForCancel}/cancel`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: reason || 'Canceled by kitchen' }) }) .then(response => response.json()) .then(data => { if (!data.error) { showToast(`Order #${currentOrderIdForCancel} canceled`, 'success'); closeCancelModal(); refreshOrders(); } else { showToast(data.message || 'Failed to cancel order', 'error'); } }) .catch(error => { console.error('Error canceling order:', error); showToast('Failed to cancel order', 'error'); }); } let reprintInProgressByOrder = {}; function reprintOrder(orderId) { if (reprintInProgressByOrder[orderId]) { return; // guard against rapid double clicks } reprintInProgressByOrder[orderId] = true; fetch(`/api/orders/${orderId}/reprint`, { method: 'POST' }) .then(response => response.json()) .then(data => { if (!data.error) { showToast((data && data.message) ? data.message : 'Receipt sent to printer', 'success'); } else { showToast(data.message || 'Failed to print receipt', 'error'); } }) .catch(error => { console.error('Error reprinting order:', error); showToast('Failed to print receipt', 'error'); }) .finally(() => { reprintInProgressByOrder[orderId] = false; }); } function showOrderDetails(orderId) { fetch(`/api/orders/${orderId}`) .then(response => response.json()) .then(data => { if (!data.error && data.order) { displayOrderDetails(data.order); } }) .catch(error => { console.error('Error fetching order details:', error); }); } function displayOrderDetails(order) { const modal = document.getElementById('detailsModal'); const content = document.getElementById('orderDetailsContent'); let itemsHtml = ''; if (order.order.items && order.order.items.length > 0) { itemsHtml = order.order.items.map(item => { let modifiersHtml = ''; // Add addons with prices if available if (item.addons && item.addons.length > 0) { item.addons.forEach(addon => { const addonName = addon.name || addon; const addonPrice = typeof addon === 'object' && addon.price != null ? addon.price : null; if (addonPrice != null && addonPrice > 0) { modifiersHtml += `
+ ${addonName} (+${formatCurrency(addonPrice)})`; } else if (addonPrice === 0) { modifiersHtml += `
+ ${addonName} (Free)`; } else { modifiersHtml += `
+ ${addonName}`; } }); } // Add excludes (no price) if (item.exclude && item.exclude.length > 0) { item.exclude.forEach(exc => { const excName = exc.name || exc; modifiersHtml += `
− NO ${excName}`; }); } return ` ${item.qty}x ${item.itemName || item.name} ${modifiersHtml} ${formatCurrency(item.price * item.qty)} `; }).join(''); } content.innerHTML = `

Order Information

Order ID: ${order.id}

Status: ${getStatusBadge(order.localStatus || order.status)}

Time: ${formatDateTime(order.createdAt)}

Type: ${order.order.type || 'N/A'}

Customer Information

Name: ${order.customer.name || 'N/A'}

Phone: ${order.customer.phoneNumber || 'N/A'}

${order.customer.email ? '

Email: ' + order.customer.email + '

' : ''} ${order.order.deliveryAddress ? '

Address: ' + order.order.deliveryAddress + '

' : ''}

Order Items

${itemsHtml} ${order.order.taxAmount ? ` ` : ''} ${order.order.deliveryFee ? ` ` : ''}
Qty Item Price
Subtotal: ${formatCurrency(order.order.amount)}
Tax (${order.order.taxRate || 0}%): ${formatCurrency(order.order.taxAmount)}
Delivery Fee: ${formatCurrency(order.order.deliveryFee)}
TOTAL: ${formatCurrency(order.totalAmount)}
${order.order.specialInstructions ? `

Special Instructions

${order.order.specialInstructions}

` : ''} ${order.order.deliveryInstructions ? `

Delivery Instructions

${order.order.deliveryInstructions}

` : ''} ${order.order.foodAllergy ? `

⚠ Food Allergy Warning

${order.order.foodAllergyNotes || 'Customer has food allergies'}

` : ''}
`; modal.classList.add('visible'); } function closeDetailsModal() { document.getElementById('detailsModal').classList.remove('visible'); } // Close modals when clicking outside window.addEventListener('click', function(event) { const statusChangeModal = document.getElementById('statusChangeModal'); const cancelModal = document.getElementById('cancelModal'); const detailsModal = document.getElementById('detailsModal'); if (event.target === statusChangeModal) { closeStatusChangeModal(); } if (event.target === cancelModal) { closeCancelModal(); } if (event.target === detailsModal) { closeDetailsModal(); } }); // Close modals with Escape key document.addEventListener('keydown', function(event) { if (event.key === 'Escape') { closeStatusChangeModal(); closeCancelModal(); closeDetailsModal(); } }); // Connection status monitoring function checkConnectionStatus() { // Check local connection (dashboard server) checkLocalConnection(); // Check external API connection checkAPIConnection(); } function checkLocalConnection() { const startTime = Date.now(); fetch('/api/health/local') .then(response => { if (response.ok) { return response.json(); } else { throw new Error('Server returned error status'); } }) .then(data => { connectionStatus.local.status = 'online'; connectionStatus.local.lastCheck = new Date(); connectionStatus.local.consecutiveFailures = 0; updateConnectionUI('local', 'online', 'Connected'); }) .catch(error => { console.error('Local connection check failed:', error); connectionStatus.local.consecutiveFailures++; if (connectionStatus.local.consecutiveFailures >= 2) { connectionStatus.local.status = 'offline'; updateConnectionUI('local', 'offline', 'Disconnected'); // Show warning toast on first detection of offline if (connectionStatus.local.consecutiveFailures === 2) { showToast('Dashboard server connection lost', 'error'); } } }); } function checkAPIConnection() { const startTime = Date.now(); fetch('/api/health/external') .then(response => { if (response.ok) { return response.json(); } else { throw new Error('Server returned error status'); } }) .then(data => { connectionStatus.api.lastCheck = new Date(); if (data.status === 'online') { // Was offline, now online - notify user if (connectionStatus.api.status === 'offline' && connectionStatus.api.consecutiveFailures >= 2) { showToast('API server connection restored', 'success'); } connectionStatus.api.status = 'online'; connectionStatus.api.consecutiveFailures = 0; connectionStatus.api.responseTime = data.responseTime; let label = 'Connected'; if (data.responseTime) { label = `${data.responseTime}ms`; } updateConnectionUI('api', 'online', label); } else if (data.status === 'unconfigured') { connectionStatus.api.status = 'unconfigured'; connectionStatus.api.consecutiveFailures = 0; updateConnectionUI('api', 'unconfigured', 'Not Configured'); } else { connectionStatus.api.consecutiveFailures++; if (connectionStatus.api.consecutiveFailures >= 2) { connectionStatus.api.status = 'offline'; updateConnectionUI('api', 'offline', data.message || 'Disconnected'); // Show warning toast on first detection of offline if (connectionStatus.api.consecutiveFailures === 2) { showToast('API server connection lost: ' + (data.message || 'Disconnected'), 'error'); } } } }) .catch(error => { console.error('API connection check failed:', error); connectionStatus.api.consecutiveFailures++; if (connectionStatus.api.consecutiveFailures >= 2) { connectionStatus.api.status = 'offline'; updateConnectionUI('api', 'offline', 'Check Failed'); } }); } function updateConnectionUI(type, status, label) { let statusItem, statusIndicator, statusLabel; if (type === 'local') { statusItem = document.getElementById('localConnectionStatus'); statusLabel = document.getElementById('localStatusLabel'); } else if (type === 'api') { statusItem = document.getElementById('apiConnectionStatus'); statusLabel = document.getElementById('apiStatusLabel'); } if (!statusItem || !statusLabel) return; statusIndicator = statusItem.querySelector('.status-indicator'); // Remove all status classes statusIndicator.classList.remove('status-online', 'status-offline', 'status-checking', 'status-unconfigured'); // Add new status class statusIndicator.classList.add('status-' + status); // Update label text statusLabel.textContent = label; // Update title attribute for tooltip let tooltipText = ''; if (type === 'local') { tooltipText = 'Dashboard Server: '; } else { tooltipText = 'API Server (api.thinklink.ai): '; } tooltipText += label; if (connectionStatus[type].lastCheck) { const lastCheckTime = connectionStatus[type].lastCheck.toLocaleTimeString(); tooltipText += ' (Last checked: ' + lastCheckTime + ')'; } statusItem.title = tooltipText; } // Manual sync trigger function manualSync() { const syncButton = document.getElementById('syncButton'); const syncText = syncButton ? syncButton.querySelector('.sync-text') : null; // Prevent multiple simultaneous sync requests if (syncButton && syncButton.classList.contains('loading')) { return; } if (syncButton) { syncButton.classList.add('loading'); syncButton.disabled = true; if (syncText) { syncText.textContent = 'Syncing...'; } } fetch('/api/sync-now', { method: 'POST' }) .then(response => response.json()) .then(data => { if (!data.error) { showToast('Checking for new orders...', 'success'); // Refresh after a short delay to allow sync to complete setTimeout(() => { refreshOrders(); }, 2000); } else { showToast(data.message || 'Sync failed', 'error'); // Remove loading state on error if (syncButton) { syncButton.classList.remove('loading'); syncButton.disabled = false; if (syncText) { syncText.textContent = 'Sync Now'; } } } }) .catch(error => { console.error('Manual sync error:', error); showToast('Sync failed', 'error'); // Remove loading state on error if (syncButton) { syncButton.classList.remove('loading'); syncButton.disabled = false; if (syncText) { syncText.textContent = 'Sync Now'; } } }); }