Initial commit
This commit is contained in:
1604
public/css/style.css
Normal file
1604
public/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
2
public/images/.gitkeep
Normal file
2
public/images/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
||||
# This file ensures the images directory is created
|
||||
|
||||
59
public/js/common.js
Normal file
59
public/js/common.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// Common utilities and functions
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
const toast = document.getElementById('toast');
|
||||
if (!toast) return;
|
||||
|
||||
toast.textContent = message;
|
||||
toast.className = 'toast visible ' + type;
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('visible');
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function formatTime(timestamp) {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateTime(timestamp) {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatCurrency(amount) {
|
||||
return '$' + (amount || 0).toFixed(2);
|
||||
}
|
||||
|
||||
// Tab switching functionality
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tabButtons = document.querySelectorAll('.tab-btn');
|
||||
const tabContents = document.querySelectorAll('.tab-content');
|
||||
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const targetTab = this.dataset.tab;
|
||||
|
||||
// Remove active class from all buttons and contents
|
||||
tabButtons.forEach(btn => btn.classList.remove('active'));
|
||||
tabContents.forEach(content => content.classList.remove('active'));
|
||||
|
||||
// Add active class to clicked button and corresponding content
|
||||
this.classList.add('active');
|
||||
const targetContent = document.getElementById(targetTab + '-tab');
|
||||
if (targetContent) {
|
||||
targetContent.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
953
public/js/dashboard.js
Normal file
953
public/js/dashboard.js
Normal file
@@ -0,0 +1,953 @@
|
||||
// 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 = '<div class="no-orders">No orders to display</div>';
|
||||
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 = `
|
||||
<button class="btn btn-success" onclick="changeStatus(${order.id}, 'preparing')">Start Preparing</button>
|
||||
`;
|
||||
} else if (status === 'preparing') {
|
||||
actions = `
|
||||
<button class="btn btn-success" onclick="changeStatus(${order.id}, 'ready')">Mark Ready</button>
|
||||
`;
|
||||
} else if (status === 'ready') {
|
||||
actions = `
|
||||
<button class="btn btn-success" onclick="changeStatus(${order.id}, 'completed')">Complete</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add cancel and reprint buttons for non-completed orders
|
||||
if (status !== 'canceled' && status !== 'completed') {
|
||||
actions += `
|
||||
<button class="btn btn-danger" onclick="openCancelModal(${order.id})">Cancel</button>
|
||||
`;
|
||||
}
|
||||
|
||||
actions += `
|
||||
<button class="btn btn-secondary" onclick="reprintOrder(${order.id})">Reprint</button>
|
||||
<button class="btn btn-secondary" onclick="showOrderDetails(${order.id})">Details</button>
|
||||
`;
|
||||
|
||||
// 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 += `<div class="item-modifier item-addon">+ Add: ${addonsList}</div>`;
|
||||
}
|
||||
|
||||
// Add excludes
|
||||
if (item.exclude && item.exclude.length > 0) {
|
||||
const excludeList = item.exclude.map(e => e.name || e).join(', ');
|
||||
modifiersHtml += `<div class="item-modifier item-exclude">− NO: ${excludeList}</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="order-item">
|
||||
<div class="item-qty-name">
|
||||
<span class="item-qty">${item.qty}x</span>
|
||||
<span class="item-name">${item.itemName || item.name || 'Item'}</span>
|
||||
</div>
|
||||
${modifiersHtml}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Food allergy warning (prominent)
|
||||
let allergyWarning = '';
|
||||
if (order.order.foodAllergy) {
|
||||
const allergyNotes = order.order.foodAllergyNotes || 'Customer has food allergies';
|
||||
allergyWarning = `
|
||||
<div class="allergy-warning">
|
||||
<div class="allergy-icon">⚠️</div>
|
||||
<div class="allergy-content">
|
||||
<div class="allergy-title">FOOD ALLERGY ALERT</div>
|
||||
<div class="allergy-notes">${allergyNotes}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Special instructions
|
||||
let specialInstructionsHtml = '';
|
||||
if (order.order.specialInstructions) {
|
||||
specialInstructionsHtml = `
|
||||
<div class="special-instructions">
|
||||
<div class="special-instructions-label">📝 Special Instructions:</div>
|
||||
<div class="special-instructions-text">${order.order.specialInstructions}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="order-card status-${status}">
|
||||
<div class="order-header">
|
||||
<div class="order-number">#${order.id}</div>
|
||||
<div class="order-badge badge-${status}">${statusBadge}</div>
|
||||
</div>
|
||||
|
||||
${allergyWarning}
|
||||
|
||||
<div class="order-info">
|
||||
<div class="order-info-row">
|
||||
<span class="order-info-label">Time:</span>
|
||||
<span class="order-info-value">${formatTime(order.createdAt)}</span>
|
||||
</div>
|
||||
<div class="order-info-row">
|
||||
<span class="order-info-label">Customer:</span>
|
||||
<span class="order-info-value">${order.customer.name || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="order-info-row">
|
||||
<span class="order-info-label">Type:</span>
|
||||
<span class="order-info-value"><span class="order-type-icon">${typeIcon}</span>${orderType}</span>
|
||||
</div>
|
||||
${order.order.deliveryAddress ? `<div class="order-info-row"><span class="order-info-label">Address:</span><span class="order-info-value">${order.order.deliveryAddress}</span></div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="order-items-section">
|
||||
<div class="order-items-title">Order Items:</div>
|
||||
${itemsDetailedHtml}
|
||||
</div>
|
||||
|
||||
${specialInstructionsHtml}
|
||||
|
||||
<div class="order-total">
|
||||
<span class="order-total-label">Total:</span>
|
||||
<span class="order-total-value">${formatCurrency(order.totalAmount)}</span>
|
||||
</div>
|
||||
|
||||
<div class="order-actions">
|
||||
${actions}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 += `<br><small style="color: #28a745; margin-left: 20px;">+ ${addonName} <span style="color: #666;">(+${formatCurrency(addonPrice)})</span></small>`;
|
||||
} else if (addonPrice === 0) {
|
||||
modifiersHtml += `<br><small style="color: #28a745; margin-left: 20px;">+ ${addonName} <span style="color: #666;">(Free)</span></small>`;
|
||||
} else {
|
||||
modifiersHtml += `<br><small style="color: #28a745; margin-left: 20px;">+ ${addonName}</small>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add excludes (no price)
|
||||
if (item.exclude && item.exclude.length > 0) {
|
||||
item.exclude.forEach(exc => {
|
||||
const excName = exc.name || exc;
|
||||
modifiersHtml += `<br><small style="color: #dc3545; margin-left: 20px;">− NO ${excName}</small>`;
|
||||
});
|
||||
}
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td style="vertical-align: top; padding: 8px;">${item.qty}x</td>
|
||||
<td style="padding: 8px;">
|
||||
<strong>${item.itemName || item.name}</strong>
|
||||
${modifiersHtml}
|
||||
</td>
|
||||
<td style="text-align: right; vertical-align: top; padding: 8px;">${formatCurrency(item.price * item.qty)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="order-details">
|
||||
<div class="detail-section">
|
||||
<h3>Order Information</h3>
|
||||
<p><strong>Order ID:</strong> ${order.id}</p>
|
||||
<p><strong>Status:</strong> ${getStatusBadge(order.localStatus || order.status)}</p>
|
||||
<p><strong>Time:</strong> ${formatDateTime(order.createdAt)}</p>
|
||||
<p><strong>Type:</strong> ${order.order.type || 'N/A'}</p>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>Customer Information</h3>
|
||||
<p><strong>Name:</strong> ${order.customer.name || 'N/A'}</p>
|
||||
<p><strong>Phone:</strong> ${order.customer.phoneNumber || 'N/A'}</p>
|
||||
${order.customer.email ? '<p><strong>Email:</strong> ' + order.customer.email + '</p>' : ''}
|
||||
${order.order.deliveryAddress ? '<p><strong>Address:</strong> ' + order.order.deliveryAddress + '</p>' : ''}
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>Order Items</h3>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 2px solid #ddd;">
|
||||
<th style="text-align: left; padding: 8px;">Qty</th>
|
||||
<th style="text-align: left; padding: 8px;">Item</th>
|
||||
<th style="text-align: right; padding: 8px;">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${itemsHtml}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style="border-top: 2px solid #ddd;">
|
||||
<td colspan="2" style="text-align: right; padding: 8px;"><strong>Subtotal:</strong></td>
|
||||
<td style="text-align: right; padding: 8px;"><strong>${formatCurrency(order.order.amount)}</strong></td>
|
||||
</tr>
|
||||
${order.order.taxAmount ? `
|
||||
<tr>
|
||||
<td colspan="2" style="text-align: right; padding: 8px;">Tax (${order.order.taxRate || 0}%):</td>
|
||||
<td style="text-align: right; padding: 8px;">${formatCurrency(order.order.taxAmount)}</td>
|
||||
</tr>` : ''}
|
||||
${order.order.deliveryFee ? `
|
||||
<tr>
|
||||
<td colspan="2" style="text-align: right; padding: 8px;">Delivery Fee:</td>
|
||||
<td style="text-align: right; padding: 8px;">${formatCurrency(order.order.deliveryFee)}</td>
|
||||
</tr>` : ''}
|
||||
<tr style="border-top: 2px solid #333;">
|
||||
<td colspan="2" style="text-align: right; padding: 8px;"><strong>TOTAL:</strong></td>
|
||||
<td style="text-align: right; padding: 8px;"><strong>${formatCurrency(order.totalAmount)}</strong></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
${order.order.specialInstructions ? `
|
||||
<div class="detail-section">
|
||||
<h3>Special Instructions</h3>
|
||||
<p>${order.order.specialInstructions}</p>
|
||||
</div>` : ''}
|
||||
|
||||
${order.order.deliveryInstructions ? `
|
||||
<div class="detail-section">
|
||||
<h3>Delivery Instructions</h3>
|
||||
<p>${order.order.deliveryInstructions}</p>
|
||||
</div>` : ''}
|
||||
|
||||
${order.order.foodAllergy ? `
|
||||
<div class="detail-section" style="background: #ffe6e6; padding: 15px; border-radius: 4px;">
|
||||
<h3 style="color: #dc3545;">⚠ Food Allergy Warning</h3>
|
||||
<p><strong>${order.order.foodAllergyNotes || 'Customer has food allergies'}</strong></p>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
709
public/js/settings.js
Normal file
709
public/js/settings.js
Normal file
@@ -0,0 +1,709 @@
|
||||
// Settings page functionality
|
||||
|
||||
// ========== Legacy functions (kept for backward compatibility) ==========
|
||||
function testPrinter() {
|
||||
const resultEl = document.getElementById('printerTestResult');
|
||||
const selectedHidden = document.getElementById('selectedPrintersJson');
|
||||
resultEl.textContent = 'Testing...';
|
||||
resultEl.style.color = '#666';
|
||||
|
||||
const body = JSON.stringify({
|
||||
selectedPrintersJson: selectedHidden ? selectedHidden.value : '[]'
|
||||
});
|
||||
|
||||
fetch('/settings/test-printer', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.error) {
|
||||
resultEl.textContent = '✓ ' + data.message;
|
||||
resultEl.style.color = '#28a745';
|
||||
} else {
|
||||
resultEl.textContent = '✗ ' + (data.message || 'Test failed');
|
||||
resultEl.style.color = '#dc3545';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Test print error:', error);
|
||||
resultEl.textContent = '✗ Network error';
|
||||
resultEl.style.color = '#dc3545';
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadLogo() {
|
||||
const fileInput = document.getElementById('logoUpload');
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (!file) {
|
||||
alert('Please select a file first');
|
||||
return;
|
||||
}
|
||||
|
||||
const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
alert('Please select a valid image file (PNG, JPG, or GIF)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('File size must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/settings/upload-logo', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error) {
|
||||
alert('Logo uploaded successfully!');
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Upload failed: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logo upload error:', error);
|
||||
alert('Upload failed: Network error');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== New Printer Management Functions ==========
|
||||
|
||||
let currentPrinterId = null;
|
||||
|
||||
// Load and display printers
|
||||
async function loadPrinters() {
|
||||
const container = document.getElementById('printer-cards-container');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/printers/list');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error || !data.printers || data.printers.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="no-printers">
|
||||
<p>No printers configured yet.</p>
|
||||
<p>Click "Add Printer" to configure your first printer.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Render printer cards
|
||||
const cardsHTML = data.printers.map(printer => createPrinterCard(printer)).join('');
|
||||
container.innerHTML = cardsHTML;
|
||||
} catch (error) {
|
||||
console.error('Failed to load printers:', error);
|
||||
container.innerHTML = `<div class="alert alert-error">Failed to load printers: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Create HTML for a printer card
|
||||
function createPrinterCard(printer) {
|
||||
const defaultBadge = printer.is_default ? '<span class="printer-badge badge-default">DEFAULT</span>' : '';
|
||||
const enabledBadge = printer.is_enabled
|
||||
? '<span class="printer-badge badge-enabled">ENABLED</span>'
|
||||
: '<span class="printer-badge badge-disabled">DISABLED</span>';
|
||||
|
||||
const typeLabel = {
|
||||
'network': 'Network',
|
||||
'com': 'Serial/COM',
|
||||
'usb': 'USB',
|
||||
'system': 'System Printer'
|
||||
}[printer.type] || printer.type;
|
||||
|
||||
return `
|
||||
<div class="printer-card ${!printer.is_enabled ? 'disabled' : ''}">
|
||||
<div class="printer-card-header">
|
||||
<div>
|
||||
<h4>${escapeHtml(printer.name)}</h4>
|
||||
<div class="printer-badges">
|
||||
${defaultBadge}
|
||||
${enabledBadge}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="printer-card-body">
|
||||
<div class="printer-info-row">
|
||||
<span class="printer-info-label">Connection:</span>
|
||||
<span class="printer-info-value">${typeLabel}: ${escapeHtml(printer.interface)}</span>
|
||||
</div>
|
||||
<div class="printer-info-row">
|
||||
<span class="printer-info-label">Paper:</span>
|
||||
<span class="printer-info-value">${printer.paper_format} (${printer.paper_width} chars) | ${printer.printer_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="printer-card-actions">
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="editPrinter(${printer.id})">Edit</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="testPrinterById(${printer.id})">Test</button>
|
||||
${!printer.is_default ? `<button type="button" class="btn btn-secondary btn-sm" onclick="setDefaultPrinter(${printer.id})">Set Default</button>` : ''}
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="togglePrinterEnabled(${printer.id})">${printer.is_enabled ? 'Disable' : 'Enable'}</button>
|
||||
${!printer.is_default ? `<button type="button" class="btn btn-danger btn-sm" onclick="deletePrinter(${printer.id})">Delete</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Escape HTML to prevent XSS
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Open add printer modal
|
||||
function openAddPrinterModal() {
|
||||
currentPrinterId = null;
|
||||
document.getElementById('printerModalTitle').textContent = 'Add Printer';
|
||||
document.getElementById('printerConfigForm').reset();
|
||||
document.getElementById('printer_id').value = '';
|
||||
|
||||
// Set defaults
|
||||
document.getElementById('paper_width').value = 48;
|
||||
document.getElementById('paper_format').value = '80mm';
|
||||
document.getElementById('printer_type_model').value = 'epson';
|
||||
document.getElementById('font_size').value = 'normal';
|
||||
document.getElementById('line_style').value = 'single';
|
||||
document.getElementById('qr_code_size').value = 3;
|
||||
document.getElementById('qr_code_correction').value = 'M';
|
||||
document.getElementById('qr_code_content_template').value = 'ORDER-{id}';
|
||||
document.getElementById('header_text').value = '';
|
||||
document.getElementById('footer_text').value = '';
|
||||
document.getElementById('business_contact_size').value = 'normal';
|
||||
|
||||
// Pre-fill Business Information from Receipt Template tab
|
||||
try {
|
||||
const tplBusinessName = document.getElementById('businessName');
|
||||
const tplBusinessAddress = document.getElementById('businessAddress');
|
||||
const tplBusinessPhone = document.getElementById('businessPhone');
|
||||
const tplBusinessWebsite = document.getElementById('businessWebsite');
|
||||
const tplBusinessEmail = document.getElementById('businessEmail');
|
||||
const tplBusinessContactSize = document.getElementById('businessContactSize');
|
||||
|
||||
if (tplBusinessName) document.getElementById('business_name').value = tplBusinessName.value || '';
|
||||
if (tplBusinessAddress) document.getElementById('business_address').value = tplBusinessAddress.value || '';
|
||||
if (tplBusinessPhone) document.getElementById('business_phone').value = tplBusinessPhone.value || '';
|
||||
if (tplBusinessWebsite) document.getElementById('business_website').value = tplBusinessWebsite.value || '';
|
||||
if (tplBusinessEmail) document.getElementById('business_email').value = tplBusinessEmail.value || '';
|
||||
if (tplBusinessContactSize) document.getElementById('business_contact_size').value = tplBusinessContactSize.value || 'normal';
|
||||
} catch (_) {}
|
||||
|
||||
// Set checkboxes
|
||||
document.getElementById('show_customer_info').checked = true;
|
||||
document.getElementById('show_order_items').checked = true;
|
||||
document.getElementById('show_prices').checked = true;
|
||||
document.getElementById('show_timestamps').checked = true;
|
||||
document.getElementById('qr_code_enabled').checked = true;
|
||||
document.getElementById('is_enabled').checked = true;
|
||||
document.getElementById('is_default').checked = false;
|
||||
|
||||
showModal('printerConfigModal');
|
||||
switchPrinterModalTab('connection');
|
||||
}
|
||||
|
||||
// Edit printer
|
||||
async function editPrinter(id) {
|
||||
try {
|
||||
const response = await fetch(`/api/printers/${id}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert('Failed to load printer: ' + data.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const printer = data.printer;
|
||||
currentPrinterId = id;
|
||||
|
||||
document.getElementById('printerModalTitle').textContent = 'Edit Printer';
|
||||
document.getElementById('printer_id').value = id;
|
||||
|
||||
// Fill form with printer data
|
||||
document.getElementById('printer_name').value = printer.name || '';
|
||||
document.getElementById('printer_type_select').value = printer.type || 'network';
|
||||
document.getElementById('printer_interface').value = printer.interface || '';
|
||||
document.getElementById('paper_format').value = printer.paper_format || '80mm';
|
||||
document.getElementById('paper_width').value = printer.paper_width || 48;
|
||||
document.getElementById('printer_type_model').value = printer.printer_type || 'epson';
|
||||
document.getElementById('font_size').value = printer.font_size || 'normal';
|
||||
document.getElementById('line_style').value = printer.line_style || 'single';
|
||||
|
||||
document.getElementById('header_text').value = printer.header_text || '';
|
||||
document.getElementById('footer_text').value = printer.footer_text || '';
|
||||
|
||||
document.getElementById('business_name').value = printer.business_name || '';
|
||||
document.getElementById('business_address').value = printer.business_address || '';
|
||||
document.getElementById('business_phone').value = printer.business_phone || '';
|
||||
document.getElementById('business_website').value = printer.business_website || '';
|
||||
document.getElementById('business_email').value = printer.business_email || '';
|
||||
document.getElementById('business_contact_size').value = printer.business_contact_size || 'normal';
|
||||
|
||||
document.getElementById('logo_path').value = printer.logo_path || '';
|
||||
document.getElementById('logo_max_width_dots').value = printer.logo_max_width_dots || '';
|
||||
|
||||
if (printer.logo_path) {
|
||||
document.getElementById('logo_preview').innerHTML = `<small>Current logo: ${printer.logo_path}</small>`;
|
||||
} else {
|
||||
document.getElementById('logo_preview').innerHTML = '';
|
||||
}
|
||||
|
||||
document.getElementById('qr_code_size').value = printer.qr_code_size || 3;
|
||||
document.getElementById('qr_code_correction').value = printer.qr_code_correction || 'M';
|
||||
document.getElementById('qr_code_content_template').value = printer.qr_code_content_template || 'ORDER-{id}';
|
||||
|
||||
document.getElementById('show_customer_info').checked = printer.show_customer_info !== false;
|
||||
document.getElementById('show_order_items').checked = printer.show_order_items !== false;
|
||||
document.getElementById('show_prices').checked = printer.show_prices !== false;
|
||||
document.getElementById('show_timestamps').checked = printer.show_timestamps !== false;
|
||||
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;
|
||||
|
||||
showModal('printerConfigModal');
|
||||
switchPrinterModalTab('connection');
|
||||
} catch (error) {
|
||||
console.error('Failed to load printer:', error);
|
||||
alert('Failed to load printer: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Save printer configuration
|
||||
async function savePrinterConfig() {
|
||||
const form = document.getElementById('printerConfigForm');
|
||||
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(form);
|
||||
const config = {};
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
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)) {
|
||||
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);
|
||||
if (!isNaN(val) && val > 0) config[key] = val;
|
||||
} else {
|
||||
config[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
const el = document.getElementById(key);
|
||||
if (el) {
|
||||
config[key] = !!el.checked;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const printerId = document.getElementById('printer_id').value;
|
||||
const url = printerId ? `/api/printers/${printerId}` : '/api/printers/create';
|
||||
const method = printerId ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert('Failed to save printer: ' + data.message);
|
||||
return;
|
||||
}
|
||||
|
||||
alert(data.message || 'Printer saved successfully');
|
||||
closePrinterModal();
|
||||
loadPrinters();
|
||||
} catch (error) {
|
||||
console.error('Failed to save printer:', error);
|
||||
alert('Failed to save printer: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete printer
|
||||
async function deletePrinter(id) {
|
||||
if (!confirm('Are you sure you want to delete this printer?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/printers/${id}`, { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert('Failed to delete printer: ' + data.message);
|
||||
return;
|
||||
}
|
||||
|
||||
alert('Printer deleted successfully');
|
||||
loadPrinters();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete printer:', error);
|
||||
alert('Failed to delete printer: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Test printer
|
||||
async function testPrinterById(id) {
|
||||
try {
|
||||
const response = await fetch(`/api/printers/${id}/test`, { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert('Test failed: ' + data.message);
|
||||
} else {
|
||||
alert(data.message || 'Test print sent successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Test print error:', error);
|
||||
alert('Test failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Set default printer
|
||||
async function setDefaultPrinter(id) {
|
||||
try {
|
||||
const response = await fetch(`/api/printers/${id}/set-default`, { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert('Failed to set default: ' + data.message);
|
||||
} else {
|
||||
loadPrinters();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set default:', error);
|
||||
alert('Failed to set default: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle printer enabled
|
||||
async function togglePrinterEnabled(id) {
|
||||
try {
|
||||
const response = await fetch(`/api/printers/${id}/toggle-enabled`, { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert('Failed to toggle printer: ' + data.message);
|
||||
} else {
|
||||
loadPrinters();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle printer:', error);
|
||||
alert('Failed to toggle printer: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload logo for specific printer
|
||||
async function uploadLogoForPrinter() {
|
||||
const fileInput = document.getElementById('logo_upload_modal');
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (!file) {
|
||||
alert('Please select a file first');
|
||||
return;
|
||||
}
|
||||
|
||||
const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
alert('Please select a valid image file (PNG, JPG, or GIF)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('File size must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
// If editing existing printer, include printer_id
|
||||
const printerId = document.getElementById('printer_id').value;
|
||||
if (printerId) {
|
||||
formData.append('printer_id', printerId);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/settings/upload-logo', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error) {
|
||||
alert('Logo uploaded successfully');
|
||||
document.getElementById('logo_path').value = data.filepath;
|
||||
document.getElementById('logo_preview').innerHTML = `<small>Logo uploaded: ${data.filepath}</small>`;
|
||||
} else {
|
||||
alert('Upload failed: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logo upload error:', error);
|
||||
alert('Upload failed: Network error');
|
||||
}
|
||||
}
|
||||
|
||||
// Detect printers for modal
|
||||
async function detectPrintersForModal() {
|
||||
const listEl = document.getElementById('detected-printers-list');
|
||||
listEl.innerHTML = '<p>Detecting printers...</p>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/printers/detect');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error || !data.printers || data.printers.length === 0) {
|
||||
listEl.innerHTML = '<p>No printers detected.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const items = data.printers.map(p => {
|
||||
const typeLabel = {
|
||||
'system': 'System',
|
||||
'com': 'COM'
|
||||
}[p.type] || p.type;
|
||||
|
||||
return `
|
||||
<div class="detected-printer-item">
|
||||
<span><strong>${escapeHtml(p.name)}</strong> (${typeLabel})</span>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="selectDetectedPrinter('${p.type}', '${escapeHtml(p.interface)}')">Use This</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
listEl.innerHTML = items;
|
||||
} catch (error) {
|
||||
console.error('Failed to detect printers:', error);
|
||||
listEl.innerHTML = '<p>Failed to detect printers.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Select a detected printer
|
||||
function selectDetectedPrinter(type, interface) {
|
||||
document.getElementById('printer_type_select').value = type;
|
||||
document.getElementById('printer_interface').value = interface;
|
||||
updateInterfaceHint();
|
||||
}
|
||||
|
||||
// Update interface hint based on connection type
|
||||
function updateInterfaceHint() {
|
||||
const type = document.getElementById('printer_type_select').value;
|
||||
const hintEl = document.getElementById('interface_hint');
|
||||
|
||||
const hints = {
|
||||
'network': 'Enter IP:Port for network printers (e.g., 192.168.1.100:9100)',
|
||||
'com': 'Enter COM port (e.g., COM1, COM3)',
|
||||
'usb': 'Enter USB device path (e.g., /dev/usb/lp0)',
|
||||
'system': 'Enter the exact printer name from Windows'
|
||||
};
|
||||
|
||||
hintEl.textContent = hints[type] || 'Enter connection address';
|
||||
}
|
||||
|
||||
// Auto-update paper width when format changes
|
||||
function updatePaperWidthFromFormat() {
|
||||
const format = document.getElementById('paper_format').value;
|
||||
const widthInput = document.getElementById('paper_width');
|
||||
|
||||
const widthMap = {
|
||||
'58mm': 32,
|
||||
'80mm': 48,
|
||||
'letter': 80
|
||||
};
|
||||
|
||||
if (widthMap[format]) {
|
||||
widthInput.value = widthMap[format];
|
||||
}
|
||||
}
|
||||
|
||||
// Modal tab switching
|
||||
function switchPrinterModalTab(tabName) {
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.printer-modal-tab-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
if (btn.getAttribute('data-tab') === tabName) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Update tab content
|
||||
document.querySelectorAll('.printer-modal-tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
document.getElementById(tabName + '-tab-content').classList.add('active');
|
||||
}
|
||||
|
||||
// Close printer modal
|
||||
function closePrinterModal() {
|
||||
hideModal('printerConfigModal');
|
||||
currentPrinterId = null;
|
||||
}
|
||||
|
||||
// Show modal
|
||||
function showModal(modalId) {
|
||||
document.getElementById(modalId).classList.add('visible');
|
||||
}
|
||||
|
||||
// Hide modal
|
||||
function hideModal(modalId) {
|
||||
document.getElementById(modalId).classList.remove('visible');
|
||||
}
|
||||
|
||||
// ========== Sound Notification Functions ==========
|
||||
|
||||
// Upload sound file
|
||||
async function uploadSound(soundType) {
|
||||
const fileInputId = soundType === 'newOrder' ? 'newOrderSoundUpload' : 'canceledOrderSoundUpload';
|
||||
const fileInput = document.getElementById(fileInputId);
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (!file) {
|
||||
alert('Please select a file first');
|
||||
return;
|
||||
}
|
||||
|
||||
const validTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
alert('Please select a valid audio file (MP3, WAV, or OGG)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert('File size must be less than 10MB');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('soundType', soundType);
|
||||
|
||||
try {
|
||||
const response = await fetch('/settings/upload-sound', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error) {
|
||||
alert('Sound uploaded successfully! Please save settings to apply changes.');
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Upload failed: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sound upload error:', error);
|
||||
alert('Upload failed: Network error');
|
||||
}
|
||||
}
|
||||
|
||||
// Test sound playback
|
||||
async function testSound(soundType) {
|
||||
try {
|
||||
// Get current settings
|
||||
const response = await fetch('/api/notification-settings');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert('Failed to load sound settings');
|
||||
return;
|
||||
}
|
||||
|
||||
const soundPath = soundType === 'newOrder'
|
||||
? (data.newOrderSoundPath || '/public/sounds/new-order-notification.mp3')
|
||||
: (data.canceledOrderSoundPath || '/public/sounds/canceled-order-notification.mp3');
|
||||
|
||||
const volumeInput = document.getElementById('soundVolume');
|
||||
const volume = volumeInput ? parseInt(volumeInput.value, 10) / 100 : 0.8;
|
||||
|
||||
const audio = new Audio(soundPath);
|
||||
audio.volume = volume;
|
||||
|
||||
audio.play().catch(error => {
|
||||
console.error('Failed to play sound:', error);
|
||||
alert('Failed to play sound. Make sure the file exists and is a valid audio file.');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Test sound error:', error);
|
||||
alert('Failed to test sound: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Update volume display
|
||||
function updateVolumeDisplay() {
|
||||
const volumeInput = document.getElementById('soundVolume');
|
||||
const volumeValue = document.getElementById('volumeValue');
|
||||
|
||||
if (volumeInput && volumeValue) {
|
||||
volumeValue.textContent = volumeInput.value;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Event Listeners ==========
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Load printers on settings page
|
||||
if (document.getElementById('printer-cards-container')) {
|
||||
loadPrinters();
|
||||
}
|
||||
|
||||
// Printer modal tab switching
|
||||
document.querySelectorAll('.printer-modal-tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
switchPrinterModalTab(this.getAttribute('data-tab'));
|
||||
});
|
||||
});
|
||||
|
||||
// Connection type change handler
|
||||
const typeSelect = document.getElementById('printer_type_select');
|
||||
if (typeSelect) {
|
||||
typeSelect.addEventListener('change', updateInterfaceHint);
|
||||
}
|
||||
|
||||
// Paper format change handler
|
||||
const formatSelect = document.getElementById('paper_format');
|
||||
if (formatSelect) {
|
||||
formatSelect.addEventListener('change', updatePaperWidthFromFormat);
|
||||
}
|
||||
|
||||
// Volume slider handler
|
||||
const volumeInput = document.getElementById('soundVolume');
|
||||
if (volumeInput) {
|
||||
volumeInput.addEventListener('input', updateVolumeDisplay);
|
||||
}
|
||||
|
||||
// Update interface placeholder based on interface type (legacy support)
|
||||
const interfaceSelect = document.getElementById('printerInterface');
|
||||
const pathInput = document.getElementById('printerPath');
|
||||
|
||||
if (interfaceSelect && pathInput) {
|
||||
interfaceSelect.addEventListener('change', function() {
|
||||
const placeholders = {
|
||||
'usb': '/dev/usb/lp0 (Linux) or COM1 (Windows)',
|
||||
'network': '192.168.1.100:9100',
|
||||
'serial': 'COM1 (Windows) or /dev/ttyS0 (Linux)'
|
||||
};
|
||||
|
||||
pathInput.placeholder = placeholders[this.value] || 'Enter printer path';
|
||||
});
|
||||
}
|
||||
});
|
||||
BIN
public/sounds/canceled-order-notification.mp3
Normal file
BIN
public/sounds/canceled-order-notification.mp3
Normal file
Binary file not shown.
BIN
public/sounds/new-order-notification.mp3
Normal file
BIN
public/sounds/new-order-notification.mp3
Normal file
Binary file not shown.
2
public/uploads/.gitkeep
Normal file
2
public/uploads/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
||||
# This file ensures the uploads directory is created for logo uploads
|
||||
|
||||
BIN
public/uploads/canceled-order-notification.mp3
Normal file
BIN
public/uploads/canceled-order-notification.mp3
Normal file
Binary file not shown.
BIN
public/uploads/new-order-notification.mp3
Normal file
BIN
public/uploads/new-order-notification.mp3
Normal file
Binary file not shown.
Reference in New Issue
Block a user