From 634148f7274019ddd66c87999dfa941c20fbd679 Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Wed, 7 Jan 2026 15:31:45 -0800 Subject: [PATCH] Add Categories table support, KDS station selection, and portal fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Categories Migration: - Add ItemCategoryID column to Items table (api/admin/addItemCategoryColumn.cfm) - Migration script to populate Categories from unified schema (api/admin/migrateToCategories.cfm) - Updated items.cfm and getForBuilder.cfm to use Categories table with fallback KDS Station Selection: - KDS now prompts for station selection on load (Kitchen, Bar, or All Stations) - Station filter persists in localStorage - Updated listForKDS.cfm to filter orders by station - Simplified KDS UI with station badge in header Portal Improvements: - Fixed drag-and-drop in station assignment (proper event propagation) - Fixed Back button links to use BASE_PATH for local development - Added console logging for debugging station assignment - Order detail API now calculates Subtotal, Tax, Tip, Total properly Admin Tools: - setupBigDeansStations.cfm - Create Kitchen and Bar stations for Big Dean's 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- api/Application.cfm | 5 +- api/admin/addItemCategoryColumn.cfm | 53 ++++ api/admin/migrateToCategories.cfm | 134 ++++++++++ api/admin/setupBigDeansStations.cfm | 60 +++++ api/menu/getForBuilder.cfm | 116 ++++++--- api/menu/items.cfm | 156 +++++++---- api/orders/getDetail.cfm | 40 ++- api/orders/listForKDS.cfm | 148 ++++++++--- kds/index.html | 390 ++++++---------------------- kds/kds.js | 247 ++++++++++-------- portal/menu-builder.html | 5 +- portal/station-assignment.html | 59 ++++- 12 files changed, 842 insertions(+), 571 deletions(-) create mode 100644 api/admin/addItemCategoryColumn.cfm create mode 100644 api/admin/migrateToCategories.cfm create mode 100644 api/admin/setupBigDeansStations.cfm diff --git a/api/Application.cfm b/api/Application.cfm index e545bc5..d24d92d 100644 --- a/api/Application.cfm +++ b/api/Application.cfm @@ -1,4 +1,4 @@ - + - + + + + + + + + + + + + + + 0 + AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID) + AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl2 WHERE tl2.TemplateItemID = i.ItemParentItemID) + ORDER BY c.CategorySortOrder, i.ItemSortOrder, i.ItemID + ", + [ { value = BusinessID, cfsqltype = "cf_sql_integer" } ], + { datasource = "payfrit" } + )> + + + 0 + OR (i.ItemParentItemID = 0 AND i.ItemIsCollapsible = 0) ) - END as ItemCategoryID, - CASE - WHEN i.ItemParentItemID = 0 AND i.ItemIsCollapsible = 0 THEN i.ItemName - ELSE COALESCE( - (SELECT cat.ItemName FROM Items cat - WHERE cat.ItemID = i.ItemParentItemID - AND cat.ItemParentItemID = 0 - AND cat.ItemIsCollapsible = 0), - '' - ) - END as CategoryName, - i.ItemName, - i.ItemDescription, - i.ItemParentItemID, - i.ItemPrice, - i.ItemIsActive, - i.ItemIsCheckedByDefault, - i.ItemRequiresChildSelection, - i.ItemMaxNumSelectionReq, - i.ItemIsCollapsible, - i.ItemSortOrder, - i.ItemStationID, - s.StationName, - s.StationColor - FROM Items i - LEFT JOIN Stations s ON s.StationID = i.ItemStationID - WHERE i.ItemBusinessID = ? - AND i.ItemIsActive = 1 - AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID) - AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl2 WHERE tl2.TemplateItemID = i.ItemParentItemID) - AND ( - i.ItemParentItemID > 0 - OR (i.ItemParentItemID = 0 AND i.ItemIsCollapsible = 0) - ) - ORDER BY i.ItemParentItemID, i.ItemSortOrder, i.ItemID - ", - [ { value = BusinessID, cfsqltype = "cf_sql_integer" } ], - { datasource = "payfrit" } - )> + ORDER BY i.ItemParentItemID, i.ItemSortOrder, i.ItemID + ", + [ { value = BusinessID, cfsqltype = "cf_sql_integer" } ], + { datasource = "payfrit" } + )> + 0 && !isNull(qPayment.PaymentTipAmount)) { + tip = qPayment.PaymentTipAmount; + } + } catch (any e) { + // Payments table may not exist or have this column, ignore + } + + // Calculate total + total = subtotal + tax + tip; + // Build response order = { "OrderID": qOrder.OrderID, "BusinessID": qOrder.OrderBusinessID, "Status": qOrder.OrderStatusID, "StatusText": getStatusText(qOrder.OrderStatusID), - "Tip": qOrder.OrderTipAmount, + "Subtotal": subtotal, + "Tax": tax, + "Tip": tip, + "Total": total, "Notes": qOrder.OrderRemarks, "CreatedOn": dateTimeFormat(qOrder.OrderAddedOn, "yyyy-mm-dd HH:nn:ss"), "UpdatedOn": len(qOrder.OrderLastEditedOn) ? dateTimeFormat(qOrder.OrderLastEditedOn, "yyyy-mm-dd HH:nn:ss") : "", diff --git a/api/orders/listForKDS.cfm b/api/orders/listForKDS.cfm index 4236d17..235ec49 100644 --- a/api/orders/listForKDS.cfm +++ b/api/orders/listForKDS.cfm @@ -1,4 +1,4 @@ - + @@ -29,6 +29,7 @@ + @@ -53,50 +54,109 @@ - + + + + + + + + - + + + 0) + ORDER BY oli.OrderLineItemID + ", [ + { value = qOrders.OrderID, cfsqltype = "cf_sql_integer" }, + { value = StationID, cfsqltype = "cf_sql_integer" } + ], { datasource = "payfrit" })> + + + @@ -109,7 +169,8 @@ "OrderLineItemRemark": qLineItems.OrderLineItemRemark, "ItemName": qLineItems.ItemName, "ItemParentItemID": qLineItems.ItemParentItemID, - "ItemIsCheckedByDefault": qLineItems.ItemIsCheckedByDefault + "ItemIsCheckedByDefault": qLineItems.ItemIsCheckedByDefault, + "ItemStationID": qLineItems.ItemStationID })> @@ -134,7 +195,8 @@ @@ -145,4 +207,4 @@ "DETAIL": cfcatch.message })> - + \ No newline at end of file diff --git a/kds/index.html b/kds/index.html index 4a1ca61..201265d 100644 --- a/kds/index.html +++ b/kds/index.html @@ -1,338 +1,96 @@ - + Payfrit KDS - Kitchen Display System + + +
-

Kitchen Display System

+

Kitchen Display System

Loading...
Connected - + +
- - - + + +
@@ -348,4 +106,4 @@ - + \ No newline at end of file diff --git a/kds/kds.js b/kds/kds.js index d95d121..a880491 100644 --- a/kds/kds.js +++ b/kds/kds.js @@ -3,32 +3,25 @@ let config = { apiBaseUrl: '/biz.payfrit.com/api', businessId: null, servicePointId: null, - refreshInterval: 5000, // 5 seconds + 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' -}; +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(); + checkStationSelection(); }); // Load config from localStorage @@ -39,6 +32,9 @@ function loadConfig() { 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 || ''; @@ -55,6 +51,110 @@ function loadConfig() { } } +// 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 = ` + + `; + + stations.forEach(s => { + const color = s.StationColor || '#666'; + html += ` + + `; + }); + + 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 = `${escapeHtml(config.stationName)}`; + } else { + badge.innerHTML = `All Stations`; + } +} + // Save config to localStorage function saveConfig() { const businessId = parseInt(document.getElementById('businessIdInput').value) || null; @@ -69,10 +169,16 @@ function saveConfig() { 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 })); @@ -89,9 +195,7 @@ function toggleConfig() { // Start auto-refresh function startAutoRefresh() { if (!config.businessId) return; - loadOrders(); - if (refreshTimer) clearInterval(refreshTimer); refreshTimer = setInterval(loadOrders, config.refreshInterval); } @@ -100,37 +204,25 @@ function startAutoRefresh() { async function loadOrders() { if (!config.businessId) return; - console.log('[loadOrders] Fetching with BusinessID:', config.businessId, 'ServicePointID:', config.servicePointId); - try { const url = `${config.apiBaseUrl}/orders/listForKDS.cfm`; - console.log('[loadOrders] URL:', url); - const response = await fetch(url, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: config.businessId, - ServicePointID: config.servicePointId || 0 + ServicePointID: config.servicePointId || 0, + StationID: config.stationId || 0 }) }); - console.log('[loadOrders] Response status:', response.status); const data = await response.json(); - console.log('[loadOrders] Response data:', data); if (data.OK) { orders = data.ORDERS || []; - console.log('[loadOrders] Orders received:', orders.length); - if (orders.length > 0) { - console.log('[loadOrders] First order LineItems:', orders[0].LineItems); - } renderOrders(); updateStatus(true, `${orders.length} active orders`); } else { - console.error('[loadOrders] API returned OK=false:', data); updateStatus(false, `Error: ${data.MESSAGE || data.ERROR}`); } } catch (error) { @@ -143,7 +235,6 @@ async function loadOrders() { function updateStatus(isConnected, message) { const dot = document.getElementById('statusDot'); const text = document.getElementById('statusText'); - if (isConnected) { dot.classList.remove('error'); text.textContent = message || 'Connected'; @@ -156,7 +247,6 @@ function updateStatus(isConnected, message) { // Render orders to DOM function renderOrders() { const grid = document.getElementById('ordersGrid'); - if (orders.length === 0) { grid.innerHTML = `
@@ -169,7 +259,6 @@ function renderOrders() { `; return; } - grid.innerHTML = orders.map(order => renderOrder(order)).join(''); } @@ -178,8 +267,6 @@ 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 ` @@ -214,115 +301,65 @@ function renderOrder(order) { // Render line item with modifiers function renderLineItem(item, allItems) { const modifiers = allItems.filter(mod => mod.OrderLineItemParentOrderLineItemID === item.OrderLineItemID); - - console.log(`[renderLineItem] Item: ${item.ItemName}, ID: ${item.OrderLineItemID}, Modifiers found: ${modifiers.length}`); - if (modifiers.length > 0) { - console.log('[renderLineItem] Modifiers:', modifiers); - } - return `
${escapeHtml(item.ItemName)}
-
×${item.OrderLineItemQuantity}
+
x${item.OrderLineItemQuantity}
- ${modifiers.length > 0 ? renderAllModifiers(modifiers, allItems) : ''} - - ${item.OrderLineItemRemark ? ` -
Note: ${escapeHtml(item.OrderLineItemRemark)}
- ` : ''} + ${item.OrderLineItemRemark ? `
Note: ${escapeHtml(item.OrderLineItemRemark)}
` : ''}
`; } -// Render all modifiers (flattened, no nested wrappers) +// Render all modifiers function renderAllModifiers(modifiers, allItems) { let html = '
'; - // Build path for each leaf modifier function getModifierPath(mod) { const path = []; let current = mod; - - // Walk up the tree to build the path while (current) { path.unshift(current.ItemName); const parentId = current.OrderLineItemParentOrderLineItemID; if (parentId === 0) break; current = allItems.find(item => item.OrderLineItemID === parentId); } - return path; } - // Collect all leaf modifiers with their paths const leafModifiers = []; - function collectLeafModifiers(mods) { mods.forEach(mod => { - // Skip default items - they weren't explicitly chosen by the customer - if (mod.ItemIsCheckedByDefault === 1 || mod.ItemIsCheckedByDefault === true || mod.ItemIsCheckedByDefault === "1") { - console.log(`[renderAllModifiers] Skipping default item: ${mod.ItemName}`); - return; - } - + if (mod.ItemIsCheckedByDefault === 1 || mod.ItemIsCheckedByDefault === true || mod.ItemIsCheckedByDefault === "1") return; const children = allItems.filter(item => item.OrderLineItemParentOrderLineItemID === mod.OrderLineItemID); if (children.length === 0) { - // This is a leaf - no children - const path = getModifierPath(mod); - leafModifiers.push({ mod, path }); + leafModifiers.push({ mod, path: getModifierPath(mod) }); } else { - // This has children, recurse into them collectLeafModifiers(children); } }); } - collectLeafModifiers(modifiers); - // Render leaf modifiers with breadcrumb paths leafModifiers.forEach(({ path }) => { - const displayText = path.join(': '); - html += `
+ ${escapeHtml(displayText)}
`; + html += `
+ ${escapeHtml(path.join(': '))}
`; }); html += '
'; return html; } -// Render modifier recursively with indentation -function renderModifierRecursive(modifier, allItems, level) { - const subModifiers = allItems.filter(mod => mod.OrderLineItemParentOrderLineItemID === modifier.OrderLineItemID); - // For KDS MVP: no indentation, just flat list - const indent = 0; // Changed from: level * 20 - - console.log(`[renderModifierRecursive] Level ${level}: ${modifier.ItemName} (ID: ${modifier.OrderLineItemID}), Sub-modifiers: ${subModifiers.length}`); - - let html = `
+ ${escapeHtml(modifier.ItemName)}
`; - - // Recursively render sub-modifiers - if (subModifiers.length > 0) { - subModifiers.forEach(sub => { - html += renderModifierRecursive(sub, allItems, level + 1); - }); - } - - return html; -} - // Render action buttons based on order status function renderActionButtons(order) { switch (order.OrderStatusID) { case STATUS.NEW: return ``; - case STATUS.PREPARING: return ``; - case STATUS.READY: return ``; - default: return ''; } @@ -333,19 +370,11 @@ 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 - }) + 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}`); @@ -368,14 +397,12 @@ function getStatusClass(statusId) { function getElapsedTime(submittedOn) { if (!submittedOn) return 0; - const submitted = new Date(submittedOn); - const now = new Date(); - return Math.floor((now - submitted) / 1000); // seconds + return Math.floor((new Date() - new Date(submittedOn)) / 1000); } function getTimeClass(seconds) { - if (seconds > 900) return 'critical'; // > 15 min - if (seconds > 600) return 'warning'; // > 10 min + if (seconds > 900) return 'critical'; + if (seconds > 600) return 'warning'; return ''; } @@ -387,11 +414,11 @@ function formatElapsedTime(seconds) { function formatSubmitTime(dateString) { if (!dateString) return ''; - const date = new Date(dateString); - return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + 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; diff --git a/portal/menu-builder.html b/portal/menu-builder.html index fbb3f64..84da046 100644 --- a/portal/menu-builder.html +++ b/portal/menu-builder.html @@ -717,7 +717,7 @@
- + @@ -993,6 +993,9 @@ async init() { console.log('[MenuBuilder] Initializing...'); + // Set back link with BASE_PATH + document.getElementById('backLink').href = BASE_PATH + '/portal/index.html#menu'; + // Check authentication const token = localStorage.getItem('payfrit_portal_token'); const savedBusiness = localStorage.getItem('payfrit_portal_business'); diff --git a/portal/station-assignment.html b/portal/station-assignment.html index 37e4d90..475d4ac 100644 --- a/portal/station-assignment.html +++ b/portal/station-assignment.html @@ -312,7 +312,7 @@
- + @@ -391,6 +391,9 @@ async init() { console.log('[StationAssignment] Initializing...'); + // Set back link with BASE_PATH + document.getElementById('backLink').href = BASE_PATH + '/portal/index.html#menu'; + // Check authentication const token = localStorage.getItem('payfrit_portal_token'); const savedBusiness = localStorage.getItem('payfrit_portal_business'); @@ -452,22 +455,30 @@ }, async loadItems() { + console.log('[StationAssignment] Loading items for BusinessID:', this.config.businessId); try { const response = await fetch(`${this.config.apiBaseUrl}/menu/items.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); + console.log('[StationAssignment] Response status:', response.status); const data = await response.json(); + console.log('[StationAssignment] Response:', data); if (data.OK) { - // Only top-level items (not modifiers) - this.items = (data.Items || []).filter(item => item.ItemParentItemID === 0); + // Only top-level items (not modifiers) - look for items with categories + this.items = (data.Items || []).filter(item => + item.ItemParentItemID === 0 || item.ItemCategoryID > 0 + ); + console.log('[StationAssignment] Filtered items count:', this.items.length); // Build assignments from existing data this.items.forEach(item => { if (item.ItemStationID) { this.assignments[item.ItemID] = item.ItemStationID; } }); + } else { + console.error('[StationAssignment] API returned OK=false:', data.ERROR, data.MESSAGE); } } catch (err) { console.error('[StationAssignment] Items error:', err); @@ -558,7 +569,9 @@ ondragover="StationAssignment.onDragOver(event)" ondragleave="StationAssignment.onDragLeave(event)" ondrop="StationAssignment.onDrop(event, ${station.StationID})"> -
+
${station.StationName.charAt(0)}
@@ -567,7 +580,9 @@
${itemsInStation.length} items
-
+
${itemsInStation.length === 0 ? 'Drop items here' : itemsInStation.map(item => ` @@ -626,28 +641,52 @@ // Drag and drop handlers onDragStart(event, itemId) { - event.dataTransfer.setData('itemId', itemId); + console.log('[StationAssignment] Drag start, itemId:', itemId); + event.dataTransfer.setData('text/plain', itemId.toString()); + event.dataTransfer.effectAllowed = 'move'; event.target.classList.add('dragging'); }, onDragEnd(event) { + console.log('[StationAssignment] Drag end'); event.target.classList.remove('dragging'); + // Clean up any lingering drag-over classes + document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over')); }, onDragOver(event) { event.preventDefault(); - event.currentTarget.classList.add('drag-over'); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'move'; + // Find the station card (might be triggered on child elements) + const stationCard = event.target.closest('.station-card'); + if (stationCard) { + stationCard.classList.add('drag-over'); + } }, onDragLeave(event) { - event.currentTarget.classList.remove('drag-over'); + // Only remove drag-over if actually leaving the station card + const stationCard = event.target.closest('.station-card'); + const relatedTarget = event.relatedTarget; + if (stationCard && (!relatedTarget || !stationCard.contains(relatedTarget))) { + stationCard.classList.remove('drag-over'); + } }, onDrop(event, stationId) { event.preventDefault(); - event.currentTarget.classList.remove('drag-over'); + event.stopPropagation(); + console.log('[StationAssignment] Drop on station:', stationId); - const itemId = parseInt(event.dataTransfer.getData('itemId')); + // Find and clean up the station card + const stationCard = event.target.closest('.station-card'); + if (stationCard) { + stationCard.classList.remove('drag-over'); + } + + const itemId = parseInt(event.dataTransfer.getData('text/plain')); + console.log('[StationAssignment] Dropped itemId:', itemId); if (itemId) { this.assignToStation(itemId, stationId); }