Feature cancelled — modifier wording handles the use case instead. Removes IsInvertedGroup from SELECTs, JSON responses, RemovedDefaults computation, and KDS/portal display logic. DB column left in place. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
588 lines
19 KiB
JavaScript
588 lines
19 KiB
JavaScript
// Configuration
|
|
let config = {
|
|
apiBaseUrl: '/api',
|
|
businessId: parseInt(localStorage.getItem('payfrit_portal_business')) || 0,
|
|
stationId: null,
|
|
stationName: null,
|
|
stationColor: null,
|
|
refreshInterval: 5000,
|
|
};
|
|
|
|
// State
|
|
let orders = [];
|
|
let stations = [];
|
|
let refreshTimer = null;
|
|
let expandedOrders = new Set();
|
|
|
|
// 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();
|
|
updateClock();
|
|
setInterval(updateClock, 1000);
|
|
|
|
// Monitor online/offline status
|
|
window.addEventListener('online', () => {
|
|
console.log('[KDS] Back online');
|
|
updateStatus(true);
|
|
loadOrders(); // Refresh immediately when back online
|
|
});
|
|
|
|
window.addEventListener('offline', () => {
|
|
console.log('[KDS] Went offline');
|
|
updateStatus(false);
|
|
});
|
|
|
|
// Initial connection check
|
|
if (!navigator.onLine) {
|
|
updateStatus(false);
|
|
}
|
|
});
|
|
|
|
// Update clock display
|
|
function updateClock() {
|
|
const now = new Date();
|
|
const hours = String(now.getHours()).padStart(2, '0');
|
|
const minutes = String(now.getMinutes()).padStart(2, '0');
|
|
const seconds = String(now.getSeconds()).padStart(2, '0');
|
|
document.getElementById('clock').textContent = `${hours}:${minutes}:${seconds}`;
|
|
}
|
|
|
|
// Load config from localStorage
|
|
function loadConfig() {
|
|
// Load KDS-specific settings (station, refresh interval)
|
|
const saved = localStorage.getItem('kds_config');
|
|
if (saved) {
|
|
try {
|
|
const parsed = JSON.parse(saved);
|
|
// Reset station selection if business changed since last KDS use
|
|
if (parsed.businessId && parsed.businessId !== config.businessId) {
|
|
config.stationId = null;
|
|
config.stationName = null;
|
|
config.stationColor = null;
|
|
saveConfigToStorage();
|
|
} else {
|
|
config.stationId = parsed.stationId !== undefined ? parsed.stationId : null;
|
|
config.stationName = parsed.stationName || null;
|
|
config.stationColor = parsed.stationColor || null;
|
|
}
|
|
config.refreshInterval = (parsed.refreshInterval || 5) * 1000;
|
|
} catch (e) {
|
|
console.error('[KDS] Failed to load config:', e);
|
|
}
|
|
}
|
|
|
|
console.log('[KDS] Config loaded:', config);
|
|
}
|
|
|
|
function saveConfigToStorage() {
|
|
localStorage.setItem('kds_config', JSON.stringify({
|
|
businessId: config.businessId,
|
|
stationId: config.stationId,
|
|
stationName: config.stationName,
|
|
stationColor: config.stationColor,
|
|
refreshInterval: config.refreshInterval / 1000
|
|
}));
|
|
}
|
|
|
|
// Check if station selection is needed
|
|
async function checkStationSelection() {
|
|
if (!config.businessId) {
|
|
console.log('[KDS] No businessId - please log in via Portal first');
|
|
updateStatus(false, 'No business selected - log in via Portal');
|
|
return;
|
|
}
|
|
|
|
console.log('[KDS] Starting with businessId:', config.businessId, 'stationId:', config.stationId);
|
|
|
|
// Load business name
|
|
await loadName();
|
|
|
|
// If station already selected (including 0 for "all"), start KDS
|
|
if (config.stationId !== null && config.stationId !== undefined) {
|
|
updateStationBadge();
|
|
startAutoRefresh();
|
|
return;
|
|
}
|
|
|
|
// Load stations to see if we need to show picker
|
|
await loadStations();
|
|
if (stations.length > 0) {
|
|
showStationSelection();
|
|
} else {
|
|
// No stations configured, default to all orders
|
|
config.stationId = 0;
|
|
updateStationBadge();
|
|
startAutoRefresh();
|
|
}
|
|
}
|
|
|
|
// Load business name from API
|
|
async function loadName() {
|
|
if (!config.businessId) return;
|
|
|
|
try {
|
|
const response = await fetch(`${config.apiBaseUrl}/businesses/get.cfm`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ BusinessID: config.businessId })
|
|
});
|
|
const data = await response.json();
|
|
if (data.OK && data.BUSINESS && data.BUSINESS.Name) {
|
|
document.getElementById('businessName').textContent = ' - ' + data.BUSINESS.Name;
|
|
}
|
|
} catch (e) {
|
|
console.error('[KDS] Failed to load business name:', e);
|
|
}
|
|
}
|
|
|
|
// 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.Color || '#666';
|
|
html += `
|
|
<button class="station-btn" style="background: ${color}; color: #fff;" onclick="selectStation(${s.StationID}, '${escapeHtml(s.Name)}', '${color}')">
|
|
${escapeHtml(s.Name)}
|
|
</button>
|
|
`;
|
|
});
|
|
|
|
buttons.innerHTML = html;
|
|
overlay.classList.remove('hidden');
|
|
}
|
|
|
|
// Select a station
|
|
function selectStation(stationId, stationName, stationColor) {
|
|
config.stationId = stationId;
|
|
config.stationName = stationName;
|
|
config.stationColor = stationColor;
|
|
|
|
// Save to localStorage
|
|
saveConfigToStorage();
|
|
|
|
// Hide overlay and start
|
|
document.getElementById('stationOverlay').classList.add('hidden');
|
|
updateStationBadge();
|
|
startAutoRefresh();
|
|
}
|
|
|
|
// Update station badge in header
|
|
function updateStationBadge() {
|
|
const badge = document.getElementById('stationBadge');
|
|
const name = config.stationId && config.stationName ? config.stationName : 'All';
|
|
badge.innerHTML = `<span class="station-name-display">/ ${escapeHtml(name)}</span>`;
|
|
}
|
|
|
|
// 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;
|
|
|
|
// Check if online before attempting fetch
|
|
if (!navigator.onLine) {
|
|
updateStatus(false);
|
|
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,
|
|
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 indicator = document.getElementById('statusIndicator');
|
|
if (isConnected) {
|
|
indicator.classList.remove('disconnected');
|
|
} else {
|
|
indicator.classList.add('disconnected');
|
|
}
|
|
}
|
|
|
|
// Render orders to DOM
|
|
function renderOrders() {
|
|
const grid = document.getElementById('ordersGrid');
|
|
console.log('renderOrders called, orders count:', orders.length);
|
|
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;
|
|
}
|
|
orders.forEach((order, i) => {
|
|
console.log(`Order ${i}: ID=${order.OrderID}, LineItems count=${order.LineItems?.length || 0}`);
|
|
if (order.LineItems) {
|
|
order.LineItems.forEach(li => {
|
|
console.log(` LineItem: ${li.Name} (ID=${li.OrderLineItemID}, ParentID=${li.ParentOrderLineItemID})`);
|
|
});
|
|
}
|
|
});
|
|
grid.innerHTML = orders.map(order => renderOrder(order)).join('');
|
|
}
|
|
|
|
// Check if a root item belongs to the current station
|
|
function isStationItem(item) {
|
|
if (!config.stationId || config.stationId === 0) return true;
|
|
const sid = parseInt(item.StationID) || 0;
|
|
return sid === config.stationId || sid === 0;
|
|
}
|
|
|
|
// Toggle expand/collapse for an order
|
|
function toggleExpand(orderId) {
|
|
if (expandedOrders.has(orderId)) {
|
|
expandedOrders.delete(orderId);
|
|
} else {
|
|
expandedOrders.add(orderId);
|
|
}
|
|
renderOrders();
|
|
}
|
|
|
|
// Render single order card
|
|
function renderOrder(order) {
|
|
const statusClass = getStatusClass(order.StatusID);
|
|
const elapsedTime = getElapsedTime(order.SubmittedOn);
|
|
const timeClass = getTimeClass(elapsedTime);
|
|
const allRootItems = order.LineItems.filter(item => item.ParentOrderLineItemID === 0);
|
|
const isFiltering = config.stationId && config.stationId > 0;
|
|
const isExpanded = expandedOrders.has(order.OrderID);
|
|
|
|
// Split into station items and other-station items
|
|
const stationItems = isFiltering ? allRootItems.filter(i => isStationItem(i)) : allRootItems;
|
|
const otherItems = isFiltering ? allRootItems.filter(i => !isStationItem(i)) : [];
|
|
const hasOtherItems = otherItems.length > 0;
|
|
|
|
// Build expand toggle button
|
|
let expandToggle = '';
|
|
if (isFiltering && hasOtherItems) {
|
|
if (isExpanded) {
|
|
expandToggle = `<button class="expand-toggle" onclick="toggleExpand(${order.OrderID})">Hide ${otherItems.length} other item${otherItems.length > 1 ? 's' : ''}</button>`;
|
|
} else {
|
|
expandToggle = `<button class="expand-toggle" onclick="toggleExpand(${order.OrderID})">Show ${otherItems.length} other item${otherItems.length > 1 ? 's' : ''}</button>`;
|
|
}
|
|
}
|
|
|
|
// Render line items: station items always, other items only when expanded
|
|
let lineItemsHtml = stationItems.map(item => renderLineItem(item, order.LineItems, false)).join('');
|
|
if (isExpanded && hasOtherItems) {
|
|
lineItemsHtml += otherItems.map(item => renderLineItem(item, order.LineItems, true)).join('');
|
|
}
|
|
|
|
return `
|
|
<div class="order-card ${statusClass}">
|
|
<div class="order-header">
|
|
<div class="order-number">#${order.OrderID}${order.OrderTypeID === 1 ? ' <span class="badge-dinein">DINE-IN</span>' : ''}${order.OrderTypeID === 2 ? ' <span class="badge-pickup">PICKUP</span>' : ''}${order.OrderTypeID === 3 ? ' <span class="badge-delivery">DELIVERY</span>' : ''}</div>
|
|
<div class="order-time">
|
|
<div class="elapsed-time ${timeClass}">${formatElapsedTime(elapsedTime)}</div>
|
|
<div class="submit-time">${formatSubmitTime(order.SubmittedOn)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="order-info">
|
|
<div><strong>${getLocationLabel(order)}:</strong> ${getLocationValue(order)}</div>
|
|
<div><strong>Customer:</strong> ${order.FirstName || ''} ${order.LastName || ''}</div>
|
|
<div><strong>Status:</strong> ${STATUS_NAMES[order.StatusID] || 'Unknown'}</div>
|
|
</div>
|
|
|
|
${order.Remarks ? `<div class="order-remarks">Note: ${escapeHtml(order.Remarks)}</div>` : ''}
|
|
|
|
<div class="line-items">
|
|
${lineItemsHtml}
|
|
</div>
|
|
${expandToggle}
|
|
|
|
<div class="action-buttons">
|
|
${renderActionButtons(order)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Render line item with expandable modifiers
|
|
function renderLineItem(item, allItems, isOtherStation = false) {
|
|
const modifiers = allItems.filter(mod => mod.ParentOrderLineItemID === item.OrderLineItemID);
|
|
console.log(`Item: ${item.Name} (ID: ${item.OrderLineItemID}) has ${modifiers.length} direct modifiers:`, modifiers.map(m => m.Name));
|
|
const otherClass = isOtherStation ? ' other-station' : '';
|
|
const doneClass = (item.StatusID === 1) ? ' line-item-done' : '';
|
|
const hasExtras = modifiers.length > 0 || item.Remark;
|
|
const extrasCount = modifiers.length + (item.Remark ? 1 : 0);
|
|
const extrasId = `extras-${item.OrderLineItemID}`;
|
|
return `
|
|
<div class="line-item${otherClass}${doneClass}">
|
|
<div class="line-item-main${hasExtras ? ' has-extras' : ''}"${hasExtras ? ` onclick="toggleExtras('${extrasId}', this)"` : ''}>
|
|
<div class="item-name">${escapeHtml(item.Name)}</div>
|
|
${hasExtras ? `<span class="mod-badge">${extrasCount}<span class="chevron">▼</span></span>` : ''}
|
|
<div class="item-qty">x${item.Quantity}</div>
|
|
</div>
|
|
${hasExtras ? `<div class="mod-extras" id="${extrasId}">
|
|
${modifiers.length > 0 ? renderAllModifiers(modifiers, allItems) : ''}
|
|
${item.Remark ? `<div class="item-remark">Note: ${escapeHtml(item.Remark)}</div>` : ''}
|
|
</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.Name);
|
|
const parentId = current.ParentOrderLineItemID;
|
|
if (parentId === 0) break;
|
|
current = allItems.find(item => item.OrderLineItemID === parentId);
|
|
}
|
|
return path;
|
|
}
|
|
|
|
const leafModifiers = [];
|
|
function collectLeafModifiers(mods, depth = 0) {
|
|
mods.forEach(mod => {
|
|
const children = allItems.filter(item => item.ParentOrderLineItemID === mod.OrderLineItemID);
|
|
if (children.length === 0) {
|
|
const path = getModifierPath(mod);
|
|
leafModifiers.push({ mod, path });
|
|
} else {
|
|
collectLeafModifiers(children, depth + 1);
|
|
}
|
|
});
|
|
}
|
|
collectLeafModifiers(modifiers);
|
|
|
|
leafModifiers.forEach(({ mod }) => {
|
|
html += `<div class="modifier">+ ${escapeHtml(mod.Name)}</div>`;
|
|
});
|
|
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
// Check if this station's items are all done for an order
|
|
function isStationDoneForOrder(order) {
|
|
if (!config.stationId || config.stationId === 0) return false;
|
|
const stationRootItems = order.LineItems.filter(li =>
|
|
li.ParentOrderLineItemID === 0 && (parseInt(li.StationID) || 0) === config.stationId
|
|
);
|
|
if (stationRootItems.length === 0) return true; // no items for this station
|
|
return stationRootItems.every(li => li.StatusID === 1);
|
|
}
|
|
|
|
// Render action buttons based on order status
|
|
function renderActionButtons(order) {
|
|
const isFiltering = config.stationId && config.stationId > 0;
|
|
|
|
if (isFiltering) {
|
|
// Station worker view: acknowledge new orders, then mark station done
|
|
if (order.StatusID === STATUS.NEW) {
|
|
return `<button class="btn btn-start" onclick="updateOrderStatus(${order.OrderID}, ${STATUS.PREPARING})">Start Preparing</button>`;
|
|
}
|
|
if (order.StatusID === STATUS.PREPARING) {
|
|
if (isStationDoneForOrder(order)) {
|
|
return `<button class="btn btn-station-done" disabled>Station Done</button>`;
|
|
} else {
|
|
return `<button class="btn btn-station-done" onclick="markStationDone(${order.OrderID})">Mark Station Done</button>`;
|
|
}
|
|
}
|
|
if (order.StatusID === STATUS.READY) {
|
|
return `<button class="btn btn-complete" onclick="updateOrderStatus(${order.OrderID}, ${STATUS.COMPLETED})">Complete</button>`;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
// Manager view (no station selected): whole-order buttons
|
|
switch (order.StatusID) {
|
|
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.');
|
|
}
|
|
}
|
|
|
|
// Mark station's items as done for an order
|
|
async function markStationDone(orderId) {
|
|
try {
|
|
const response = await fetch(`${config.apiBaseUrl}/orders/markStationDone.cfm`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ OrderID: orderId, StationID: config.stationId })
|
|
});
|
|
const data = await response.json();
|
|
if (data.OK) {
|
|
await loadOrders();
|
|
} else {
|
|
alert(`Failed to mark station done: ${data.MESSAGE || data.ERROR}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to mark station done:', error);
|
|
alert('Failed to mark station done. Please try again.');
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
// Get location label based on order type (Table vs Type)
|
|
function getLocationLabel(order) {
|
|
// OrderTypeID: 1=Dine-In, 2=Takeaway, 3=Delivery
|
|
if (order.OrderTypeID === 2 || order.OrderTypeID === 3) {
|
|
return 'Type';
|
|
}
|
|
return 'Table';
|
|
}
|
|
|
|
// Get location value based on order type
|
|
function getLocationValue(order) {
|
|
// OrderTypeID: 1=Dine-In, 2=Takeaway, 3=Delivery
|
|
if (order.OrderTypeID === 2) {
|
|
return 'Takeaway';
|
|
}
|
|
if (order.OrderTypeID === 3) {
|
|
return 'Delivery';
|
|
}
|
|
// Dine-in: show service point name
|
|
return order.Name || 'N/A';
|
|
}
|
|
|
|
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 toggleExtras(id, mainEl) {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.classList.toggle('open');
|
|
const chevron = mainEl.querySelector('.chevron');
|
|
if (chevron) chevron.classList.toggle('open');
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|