This repository has been archived on 2026-03-21. You can view files and clone it, but cannot push or open issues or pull requests.
payfrit-biz/kds/kds.js
John Mizerek a8257b2509 Fix inverted modifier groups in KDS
The inverted group header item isn't always an order line item itself,
so RemovedDefaults was never computed. Now detects inverted groups
via children's ParentIsInvertedGroup flag and attaches RemovedDefaults
to the first child as a proxy. KDS JS handles both patterns.

Also skips showing default modifiers from inverted groups since those
are represented by "NO removed-item" instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:06:18 -07:00

594 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 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' : '';
return `
<div class="line-item${otherClass}${doneClass}">
<div class="line-item-main">
<div class="item-name">${escapeHtml(item.Name)}</div>
<div class="item-qty">x${item.Quantity}</div>
</div>
${modifiers.length > 0 ? renderAllModifiers(modifiers, allItems) : ''}
${item.Remark ? `<div class="item-remark">Note: ${escapeHtml(item.Remark)}</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 => {
// Inverted groups: show removed defaults with "NO" prefix instead of listing all selected defaults
// Check both the item itself (if group header is in order) and proxy (first child carries the data)
const isInverted = mod.IsInvertedGroup || mod.ISINVERTEDGROUP || mod.IsInvertedGroupProxy || mod.ISINVERTEDGROUPPROXY;
if (isInverted) {
const removed = mod.RemovedDefaults || mod.REMOVEDDEFAULTS || [];
if (removed.length > 0) {
const groupName = mod.ItemParentName || mod.Name;
removed.forEach(name => {
leafModifiers.push({ mod: { Name: 'NO ' + name, ItemParentName: groupName }, path: [] });
});
}
return;
}
// Skip default modifiers inside inverted groups — handled above with "NO" prefix
if (mod.IsCheckedByDefault && (mod.ParentIsInvertedGroup || mod.PARENTISINVERTEDGROUP)) return;
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 }) => {
const displayText = mod.ItemParentName
? `${mod.ItemParentName}: ${mod.Name}`
: mod.Name;
html += `<div class="modifier">+ ${escapeHtml(displayText)}</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 escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}