Previously the KDS was skipping modifiers marked as "checked by default", but for exclusive selection groups (like drink choices) the customer's actual selection should always be visible to the kitchen. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
428 lines
13 KiB
JavaScript
428 lines
13 KiB
JavaScript
// Configuration
|
|
let config = {
|
|
apiBaseUrl: 'https://biz.payfrit.com/api',
|
|
businessId: null,
|
|
servicePointId: null,
|
|
stationId: null,
|
|
stationName: null,
|
|
stationColor: null,
|
|
refreshInterval: 5000,
|
|
};
|
|
|
|
// State
|
|
let orders = [];
|
|
let stations = [];
|
|
let refreshTimer = null;
|
|
|
|
// Status ID mapping
|
|
const STATUS = { NEW: 1, PREPARING: 2, READY: 3, COMPLETED: 4 };
|
|
const STATUS_NAMES = { 1: 'New', 2: 'Preparing', 3: 'Ready', 4: 'Completed' };
|
|
|
|
// Initialize
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadConfig();
|
|
checkStationSelection();
|
|
});
|
|
|
|
// Load config from localStorage
|
|
function loadConfig() {
|
|
const saved = localStorage.getItem('kds_config');
|
|
if (saved) {
|
|
try {
|
|
const parsed = JSON.parse(saved);
|
|
config.businessId = parsed.businessId || null;
|
|
config.servicePointId = parsed.servicePointId || null;
|
|
config.stationId = parsed.stationId || null;
|
|
config.stationName = parsed.stationName || null;
|
|
config.stationColor = parsed.stationColor || null;
|
|
config.refreshInterval = (parsed.refreshInterval || 5) * 1000;
|
|
|
|
document.getElementById('businessIdInput').value = config.businessId || '';
|
|
document.getElementById('servicePointIdInput').value = config.servicePointId || '';
|
|
document.getElementById('refreshIntervalInput').value = config.refreshInterval / 1000;
|
|
} catch (e) {
|
|
console.error('Failed to load config:', e);
|
|
}
|
|
}
|
|
|
|
// If no business ID, show config panel
|
|
if (!config.businessId) {
|
|
toggleConfig();
|
|
}
|
|
}
|
|
|
|
// Check if station selection is needed
|
|
async function checkStationSelection() {
|
|
if (!config.businessId) return;
|
|
|
|
// If station already selected, start KDS
|
|
if (config.stationId !== null) {
|
|
updateStationBadge();
|
|
startAutoRefresh();
|
|
return;
|
|
}
|
|
|
|
// Load stations and show selection
|
|
await loadStations();
|
|
if (stations.length > 0) {
|
|
showStationSelection();
|
|
} else {
|
|
// No stations configured, just start with all orders
|
|
startAutoRefresh();
|
|
}
|
|
}
|
|
|
|
// Load stations from API
|
|
async function loadStations() {
|
|
if (!config.businessId) return;
|
|
|
|
try {
|
|
const response = await fetch(`${config.apiBaseUrl}/stations/list.cfm`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ BusinessID: config.businessId })
|
|
});
|
|
const data = await response.json();
|
|
if (data.OK) {
|
|
stations = data.STATIONS || [];
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load stations:', e);
|
|
}
|
|
}
|
|
|
|
// Show station selection overlay
|
|
async function showStationSelection() {
|
|
if (stations.length === 0) {
|
|
await loadStations();
|
|
}
|
|
|
|
const overlay = document.getElementById('stationOverlay');
|
|
const buttons = document.getElementById('stationButtons');
|
|
|
|
let html = `
|
|
<button class="station-btn all-stations" onclick="selectStation(0, 'All Stations', null)">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
|
</svg>
|
|
All Stations
|
|
</button>
|
|
`;
|
|
|
|
stations.forEach(s => {
|
|
const color = s.StationColor || '#666';
|
|
html += `
|
|
<button class="station-btn" style="background: ${color}; color: #fff;" onclick="selectStation(${s.StationID}, '${escapeHtml(s.StationName)}', '${color}')">
|
|
${escapeHtml(s.StationName)}
|
|
</button>
|
|
`;
|
|
});
|
|
|
|
buttons.innerHTML = html;
|
|
overlay.classList.remove('hidden');
|
|
}
|
|
|
|
// Select a station
|
|
function selectStation(stationId, stationName, stationColor) {
|
|
config.stationId = stationId || null;
|
|
config.stationName = stationName;
|
|
config.stationColor = stationColor;
|
|
|
|
// Save to localStorage
|
|
localStorage.setItem('kds_config', JSON.stringify({
|
|
businessId: config.businessId,
|
|
servicePointId: config.servicePointId,
|
|
stationId: config.stationId,
|
|
stationName: config.stationName,
|
|
stationColor: config.stationColor,
|
|
refreshInterval: config.refreshInterval / 1000
|
|
}));
|
|
|
|
// Hide overlay and start
|
|
document.getElementById('stationOverlay').classList.add('hidden');
|
|
updateStationBadge();
|
|
startAutoRefresh();
|
|
}
|
|
|
|
// Update station badge in header
|
|
function updateStationBadge() {
|
|
const badge = document.getElementById('stationBadge');
|
|
if (config.stationId && config.stationName) {
|
|
const color = config.stationColor || '#3b82f6';
|
|
badge.innerHTML = `<span class="station-name-display" style="background: ${color}; color: #fff;">${escapeHtml(config.stationName)}</span>`;
|
|
} else {
|
|
badge.innerHTML = `<span class="station-name-display" style="background: #3b82f6; color: #fff;">All Stations</span>`;
|
|
}
|
|
}
|
|
|
|
// Save config to localStorage
|
|
function saveConfig() {
|
|
const businessId = parseInt(document.getElementById('businessIdInput').value) || null;
|
|
const servicePointId = parseInt(document.getElementById('servicePointIdInput').value) || null;
|
|
const refreshInterval = parseInt(document.getElementById('refreshIntervalInput').value) || 5;
|
|
|
|
if (!businessId) {
|
|
alert('Business ID is required');
|
|
return;
|
|
}
|
|
|
|
config.businessId = businessId;
|
|
config.servicePointId = servicePointId;
|
|
config.refreshInterval = refreshInterval * 1000;
|
|
config.stationId = null; // Reset station selection when changing business
|
|
config.stationName = null;
|
|
config.stationColor = null;
|
|
|
|
localStorage.setItem('kds_config', JSON.stringify({
|
|
businessId: config.businessId,
|
|
servicePointId: config.servicePointId,
|
|
stationId: null,
|
|
stationName: null,
|
|
stationColor: null,
|
|
refreshInterval: refreshInterval
|
|
}));
|
|
|
|
toggleConfig();
|
|
location.reload();
|
|
}
|
|
|
|
// Toggle config panel
|
|
function toggleConfig() {
|
|
const panel = document.getElementById('configPanel');
|
|
panel.classList.toggle('show');
|
|
}
|
|
|
|
// Start auto-refresh
|
|
function startAutoRefresh() {
|
|
if (!config.businessId) return;
|
|
loadOrders();
|
|
if (refreshTimer) clearInterval(refreshTimer);
|
|
refreshTimer = setInterval(loadOrders, config.refreshInterval);
|
|
}
|
|
|
|
// Load orders from API
|
|
async function loadOrders() {
|
|
if (!config.businessId) return;
|
|
|
|
try {
|
|
const url = `${config.apiBaseUrl}/orders/listForKDS.cfm`;
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
BusinessID: config.businessId,
|
|
ServicePointID: config.servicePointId || 0,
|
|
StationID: config.stationId || 0
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.OK) {
|
|
orders = data.ORDERS || [];
|
|
renderOrders();
|
|
updateStatus(true, `${orders.length} active orders`);
|
|
} else {
|
|
updateStatus(false, `Error: ${data.MESSAGE || data.ERROR}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load orders:', error);
|
|
updateStatus(false, 'Connection error');
|
|
}
|
|
}
|
|
|
|
// Update status indicator
|
|
function updateStatus(isConnected, message) {
|
|
const dot = document.getElementById('statusDot');
|
|
const text = document.getElementById('statusText');
|
|
if (isConnected) {
|
|
dot.classList.remove('error');
|
|
text.textContent = message || 'Connected';
|
|
} else {
|
|
dot.classList.add('error');
|
|
text.textContent = message || 'Disconnected';
|
|
}
|
|
}
|
|
|
|
// Render orders to DOM
|
|
function renderOrders() {
|
|
const grid = document.getElementById('ordersGrid');
|
|
if (orders.length === 0) {
|
|
grid.innerHTML = `
|
|
<div class="empty-state">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
|
</svg>
|
|
<h2>No Active Orders</h2>
|
|
<p>New orders will appear here automatically</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
grid.innerHTML = orders.map(order => renderOrder(order)).join('');
|
|
}
|
|
|
|
// Render single order card
|
|
function renderOrder(order) {
|
|
const statusClass = getStatusClass(order.OrderStatusID);
|
|
const elapsedTime = getElapsedTime(order.OrderSubmittedOn);
|
|
const timeClass = getTimeClass(elapsedTime);
|
|
const rootItems = order.LineItems.filter(item => item.OrderLineItemParentOrderLineItemID === 0);
|
|
|
|
return `
|
|
<div class="order-card ${statusClass}">
|
|
<div class="order-header">
|
|
<div class="order-number">#${order.OrderID}</div>
|
|
<div class="order-time">
|
|
<div class="elapsed-time ${timeClass}">${formatElapsedTime(elapsedTime)}</div>
|
|
<div class="submit-time">${formatSubmitTime(order.OrderSubmittedOn)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="order-info">
|
|
<div><strong>Table:</strong> ${order.ServicePointName || 'N/A'}</div>
|
|
<div><strong>Customer:</strong> ${order.UserFirstName || ''} ${order.UserLastName || ''}</div>
|
|
<div><strong>Status:</strong> ${STATUS_NAMES[order.OrderStatusID] || 'Unknown'}</div>
|
|
</div>
|
|
|
|
${order.OrderRemarks ? `<div class="order-remarks">Note: ${escapeHtml(order.OrderRemarks)}</div>` : ''}
|
|
|
|
<div class="line-items">
|
|
${rootItems.map(item => renderLineItem(item, order.LineItems)).join('')}
|
|
</div>
|
|
|
|
<div class="action-buttons">
|
|
${renderActionButtons(order)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Render line item with modifiers
|
|
function renderLineItem(item, allItems) {
|
|
const modifiers = allItems.filter(mod => mod.OrderLineItemParentOrderLineItemID === item.OrderLineItemID);
|
|
return `
|
|
<div class="line-item">
|
|
<div class="line-item-main">
|
|
<div class="item-name">${escapeHtml(item.ItemName)}</div>
|
|
<div class="item-qty">x${item.OrderLineItemQuantity}</div>
|
|
</div>
|
|
${modifiers.length > 0 ? renderAllModifiers(modifiers, allItems) : ''}
|
|
${item.OrderLineItemRemark ? `<div class="item-remark">Note: ${escapeHtml(item.OrderLineItemRemark)}</div>` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Render all modifiers
|
|
function renderAllModifiers(modifiers, allItems) {
|
|
let html = '<div class="modifiers">';
|
|
|
|
function getModifierPath(mod) {
|
|
const path = [];
|
|
let current = mod;
|
|
while (current) {
|
|
path.unshift(current.ItemName);
|
|
const parentId = current.OrderLineItemParentOrderLineItemID;
|
|
if (parentId === 0) break;
|
|
current = allItems.find(item => item.OrderLineItemID === parentId);
|
|
}
|
|
return path;
|
|
}
|
|
|
|
const leafModifiers = [];
|
|
function collectLeafModifiers(mods) {
|
|
mods.forEach(mod => {
|
|
// Show ALL selected modifiers - don't skip defaults
|
|
// The customer made a choice and it should be visible on the KDS
|
|
const children = allItems.filter(item => item.OrderLineItemParentOrderLineItemID === mod.OrderLineItemID);
|
|
if (children.length === 0) {
|
|
// This is a leaf node (actual selection)
|
|
leafModifiers.push({ mod, path: getModifierPath(mod) });
|
|
} else {
|
|
// Has children, recurse deeper
|
|
collectLeafModifiers(children);
|
|
}
|
|
});
|
|
}
|
|
collectLeafModifiers(modifiers);
|
|
|
|
leafModifiers.forEach(({ path }) => {
|
|
html += `<div class="modifier">+ ${escapeHtml(path.join(': '))}</div>`;
|
|
});
|
|
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
// Render action buttons based on order status
|
|
function renderActionButtons(order) {
|
|
switch (order.OrderStatusID) {
|
|
case STATUS.NEW:
|
|
return `<button class="btn btn-start" onclick="updateOrderStatus(${order.OrderID}, ${STATUS.PREPARING})">Start Preparing</button>`;
|
|
case STATUS.PREPARING:
|
|
return `<button class="btn btn-ready" onclick="updateOrderStatus(${order.OrderID}, ${STATUS.READY})">Mark as Ready</button>`;
|
|
case STATUS.READY:
|
|
return `<button class="btn btn-complete" onclick="updateOrderStatus(${order.OrderID}, ${STATUS.COMPLETED})">Complete</button>`;
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// Update order status
|
|
async function updateOrderStatus(orderId, newStatusId) {
|
|
try {
|
|
const response = await fetch(`${config.apiBaseUrl}/orders/updateStatus.cfm`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ OrderID: orderId, StatusID: newStatusId })
|
|
});
|
|
const data = await response.json();
|
|
if (data.OK) {
|
|
await loadOrders();
|
|
} else {
|
|
alert(`Failed to update order: ${data.MESSAGE || data.ERROR}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to update order status:', error);
|
|
alert('Failed to update order status. Please try again.');
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
function getStatusClass(statusId) {
|
|
switch (statusId) {
|
|
case STATUS.NEW: return 'new';
|
|
case STATUS.PREPARING: return 'preparing';
|
|
case STATUS.READY: return 'ready';
|
|
default: return '';
|
|
}
|
|
}
|
|
|
|
function getElapsedTime(submittedOn) {
|
|
if (!submittedOn) return 0;
|
|
return Math.floor((new Date() - new Date(submittedOn)) / 1000);
|
|
}
|
|
|
|
function getTimeClass(seconds) {
|
|
if (seconds > 900) return 'critical';
|
|
if (seconds > 600) return 'warning';
|
|
return '';
|
|
}
|
|
|
|
function formatElapsedTime(seconds) {
|
|
const minutes = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
function formatSubmitTime(dateString) {
|
|
if (!dateString) return '';
|
|
return new Date(dateString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|