payfrit-works/kds/kds.js
John Mizerek d7f68e8098 Add KDS web interface files
- index.html: Complete KDS interface with dark theme
  - Responsive grid layout for order cards
  - Status indicator with connection monitoring
  - Configuration panel for settings
  - Empty state display

- kds.js: Real-time order management JavaScript
  - Auto-refresh with configurable interval
  - Order status updates via API
  - Elapsed time calculation with visual warnings
  - Hierarchical line item rendering with recursive modifiers
  - LocalStorage-based configuration persistence

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-29 17:46:11 -08:00

326 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Configuration
let config = {
apiBaseUrl: '/biz.payfrit.com/api',
businessId: null,
servicePointId: null,
refreshInterval: 5000, // 5 seconds
};
// State
let orders = [];
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();
startAutoRefresh();
});
// 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.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();
}
}
// 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;
localStorage.setItem('kds_config', JSON.stringify({
businessId: config.businessId,
servicePointId: config.servicePointId,
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 response = await fetch(`${config.apiBaseUrl}/orders/listForKDS.cfm`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
BusinessID: config.businessId,
ServicePointID: config.servicePointId || 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);
// Group line items by root items and their modifiers
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">×${item.OrderLineItemQuantity}</div>
</div>
${modifiers.length > 0 ? `
<div class="modifiers">
${modifiers.map(mod => renderModifier(mod, allItems)).join('')}
</div>
` : ''}
${item.OrderLineItemRemark ? `
<div class="item-remark">Note: ${escapeHtml(item.OrderLineItemRemark)}</div>
` : ''}
</div>
`;
}
// Render modifier (recursive for nested modifiers)
function renderModifier(modifier, allItems) {
const subModifiers = allItems.filter(mod => mod.OrderLineItemParentOrderLineItemID === modifier.OrderLineItemID);
return `
<div class="modifier">
+ ${escapeHtml(modifier.ItemName)}
${subModifiers.length > 0 ? `
<div class="modifiers">
${subModifiers.map(sub => renderModifier(sub, allItems)).join('')}
</div>
` : ''}
</div>
`;
}
// 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) {
// Immediately reload orders to reflect the change
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;
const submitted = new Date(submittedOn);
const now = new Date();
return Math.floor((now - submitted) / 1000); // seconds
}
function getTimeClass(seconds) {
if (seconds > 900) return 'critical'; // > 15 min
if (seconds > 600) return 'warning'; // > 10 min
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 '';
const date = new Date(dateString);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}