From 0a103806393eaf4c5141bb8c3c4c352f1f96f493 Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Mon, 12 Jan 2026 18:45:06 -0800 Subject: [PATCH] Add template modifier support and fix KDS breadcrumbs - setLineItem.cfm: Attach default children from ItemTemplateLinks (fixes drink choices not being saved for combos) - listForKDS.cfm: Include ItemParentName for modifier categories - kds.js: Display modifiers as "Category: Selection" format - Various other accumulated fixes for menu builder, orders, and admin Co-Authored-By: Claude Opus 4.5 --- api/Application.cfm | 3 + api/admin/clearCarts.cfm | 22 +++ api/admin/debugEmployees.cfm | 55 +++++++ api/admin/debugUserByPhone.cfm | 70 +++++++++ api/admin/setEmployeeActive.cfm | 67 ++++++++ api/menu/getForBuilder.cfm | 53 ++++++- api/menu/saveFromBuilder.cfm | 110 +++++++++++++ api/orders/getDetail.cfm | 56 ++++--- api/orders/listForKDS.cfm | 9 +- api/orders/setLineItem.cfm | 270 +++++++++++++++++++++----------- api/orders/setOrderType.cfm | 66 +++++--- api/orders/updateStatus.cfm | 63 +++++++- api/stripe/webhook.cfm | 12 +- api/tasks/accept.cfm | 13 +- api/tasks/complete.cfm | 4 +- kds/index.html | 2 +- kds/kds.js | 30 +++- portal/menu-builder.html | 239 ++++++++++++++++++++++++++-- 18 files changed, 965 insertions(+), 179 deletions(-) create mode 100644 api/admin/clearCarts.cfm create mode 100644 api/admin/debugUserByPhone.cfm create mode 100644 api/admin/setEmployeeActive.cfm diff --git a/api/Application.cfm b/api/Application.cfm index 11a5036..b87fdd0 100644 --- a/api/Application.cfm +++ b/api/Application.cfm @@ -92,6 +92,7 @@ if (len(request._api_path)) { if (findNoCase("/api/menu/items.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/addresses/states.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/debug/", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/admin/", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/orders/getOrCreateCart.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/orders/getCart.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/orders/setLineItem.cfm", request._api_path)) request._api_isPublic = true; @@ -185,6 +186,8 @@ if (len(request._api_path)) { if (findNoCase("/api/admin/debugChatMessages.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/cleanupDuplicateEmployees.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/debugEmployees.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/admin/debugUserByPhone.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/admin/setEmployeeActive.cfm", request._api_path)) request._api_isPublic = true; // Setup/Import endpoints if (findNoCase("/api/setup/importBusiness.cfm", request._api_path)) request._api_isPublic = true; diff --git a/api/admin/clearCarts.cfm b/api/admin/clearCarts.cfm new file mode 100644 index 0000000..097b5cc --- /dev/null +++ b/api/admin/clearCarts.cfm @@ -0,0 +1,22 @@ + + + + +// Delete cart orders (status 0) to reset for testing +result = queryExecute(" + DELETE FROM OrderLineItems + WHERE OrderLineItemOrderID IN ( + SELECT OrderID FROM Orders WHERE OrderStatusID = 0 + ) +", {}, { datasource = "payfrit" }); + +result2 = queryExecute(" + DELETE FROM Orders WHERE OrderStatusID = 0 +", {}, { datasource = "payfrit" }); + +writeOutput(serializeJSON({ + "OK": true, + "MESSAGE": "Deleted all cart orders (status 0)", + "DELETED_ROWS": result2.recordCount ?: "unknown" +})); + diff --git a/api/admin/debugEmployees.cfm b/api/admin/debugEmployees.cfm index 51107f1..afb1e9d 100644 --- a/api/admin/debugEmployees.cfm +++ b/api/admin/debugEmployees.cfm @@ -9,6 +9,61 @@ try { if (len(requestBody)) data = deserializeJSON(requestBody); } catch (any e) {} +// If Phone provided, look up user and their employee records +if (structKeyExists(data, "Phone") && len(data.Phone)) { + phone = reReplace(data.Phone, "[^0-9]", "", "all"); + + qUser = queryExecute(" + SELECT UserID, UserFirstName, UserLastName, UserEmailAddress, UserContactNumber + FROM Users + WHERE REPLACE(REPLACE(REPLACE(UserContactNumber, '-', ''), '(', ''), ')', '') LIKE ? + OR UserContactNumber LIKE ? + ", [ + { value: "%" & phone & "%", cfsqltype: "cf_sql_varchar" }, + { value: "%" & phone & "%", cfsqltype: "cf_sql_varchar" } + ], { datasource: "payfrit" }); + + if (qUser.recordCount == 0) { + writeOutput(serializeJSON({ "OK": false, "ERROR": "user_not_found", "PHONE": phone })); + abort; + } + + userId = qUser.UserID; + + qEmployees = queryExecute(" + SELECT e.EmployeeID, e.BusinessID, e.EmployeeStatusID, + CAST(e.EmployeeIsActive AS UNSIGNED) AS EmployeeIsActive, + b.BusinessName + FROM lt_Users_Businesses_Employees e + JOIN Businesses b ON e.BusinessID = b.BusinessID + WHERE e.UserID = ? + ", [{ value: userId, cfsqltype: "cf_sql_integer" }], { datasource: "payfrit" }); + + employees = []; + for (row in qEmployees) { + arrayAppend(employees, { + "EmployeeID": row.EmployeeID, + "BusinessID": row.BusinessID, + "BusinessName": row.BusinessName, + "StatusID": row.EmployeeStatusID, + "IsActive": row.EmployeeIsActive + }); + } + + writeOutput(serializeJSON({ + "OK": true, + "USER": { + "UserID": qUser.UserID, + "Name": trim(qUser.UserFirstName & " " & qUser.UserLastName), + "Email": qUser.UserEmailAddress, + "Phone": qUser.UserContactNumber + }, + "EMPLOYEES": employees + })); + abort; +} + +// Original behavior - list employees by BusinessID businessId = structKeyExists(data, "BusinessID") ? val(data.BusinessID) : 17; q = queryExecute(" diff --git a/api/admin/debugUserByPhone.cfm b/api/admin/debugUserByPhone.cfm new file mode 100644 index 0000000..f8bec5e --- /dev/null +++ b/api/admin/debugUserByPhone.cfm @@ -0,0 +1,70 @@ + + + + + +data = {}; +try { + requestBody = toString(getHttpRequestData().content); + if (len(requestBody)) data = deserializeJSON(requestBody); +} catch (any e) {} + +phone = structKeyExists(data, "Phone") ? data.Phone : ""; +// Strip non-digits +phone = reReplace(phone, "[^0-9]", "", "all"); + +if (len(phone) == 0) { + writeOutput(serializeJSON({ "OK": false, "ERROR": "missing_phone" })); + abort; +} + +// Find user by phone +qUser = queryExecute(" + SELECT UserID, UserFirstName, UserLastName, UserEmailAddress, UserContactNumber + FROM Users + WHERE REPLACE(REPLACE(REPLACE(UserContactNumber, '-', ''), '(', ''), ')', '') LIKE ? + OR UserContactNumber LIKE ? +", [ + { value: "%" & phone & "%", cfsqltype: "cf_sql_varchar" }, + { value: "%" & phone & "%", cfsqltype: "cf_sql_varchar" } +], { datasource: "payfrit" }); + +if (qUser.recordCount == 0) { + writeOutput(serializeJSON({ "OK": false, "ERROR": "user_not_found", "PHONE": phone })); + abort; +} + +userId = qUser.UserID; + +// Get all employee records for this user +qEmployees = queryExecute(" + SELECT e.EmployeeID, e.BusinessID, e.EmployeeStatusID, + CAST(e.EmployeeIsActive AS UNSIGNED) AS EmployeeIsActive, + b.BusinessName + FROM lt_Users_Businesses_Employees e + JOIN Businesses b ON e.BusinessID = b.BusinessID + WHERE e.UserID = ? +", [{ value: userId, cfsqltype: "cf_sql_integer" }], { datasource: "payfrit" }); + +employees = []; +for (row in qEmployees) { + arrayAppend(employees, { + "EmployeeID": row.EmployeeID, + "BusinessID": row.BusinessID, + "BusinessName": row.BusinessName, + "StatusID": row.EmployeeStatusID, + "IsActive": row.EmployeeIsActive + }); +} + +writeOutput(serializeJSON({ + "OK": true, + "USER": { + "UserID": qUser.UserID, + "Name": trim(qUser.UserFirstName & " " & qUser.UserLastName), + "Email": qUser.UserEmailAddress, + "Phone": qUser.UserContactNumber + }, + "EMPLOYEES": employees +})); + diff --git a/api/admin/setEmployeeActive.cfm b/api/admin/setEmployeeActive.cfm new file mode 100644 index 0000000..cf39dcb --- /dev/null +++ b/api/admin/setEmployeeActive.cfm @@ -0,0 +1,67 @@ + + + + + +data = {}; +try { + requestBody = toString(getHttpRequestData().content); + if (len(requestBody)) data = deserializeJSON(requestBody); +} catch (any e) {} + +businessId = structKeyExists(data, "BusinessID") ? val(data.BusinessID) : 0; +userId = structKeyExists(data, "UserID") ? val(data.UserID) : 0; +isActive = structKeyExists(data, "IsActive") ? (data.IsActive ? 1 : 0) : 1; + +if (businessId <= 0 || userId <= 0) { + writeOutput(serializeJSON({ "OK": false, "ERROR": "missing_params", "MESSAGE": "BusinessID and UserID required" })); + abort; +} + +try { + // Update employee record + queryExecute(" + UPDATE lt_Users_Businesses_Employees + SET EmployeeIsActive = ? + WHERE BusinessID = ? AND UserID = ? + ", [ + { value: isActive, cfsqltype: "cf_sql_bit" }, + { value: businessId, cfsqltype: "cf_sql_integer" }, + { value: userId, cfsqltype: "cf_sql_integer" } + ], { datasource: "payfrit" }); + + // Get updated record + q = queryExecute(" + SELECT e.EmployeeID, e.BusinessID, e.UserID, e.EmployeeStatusID, + CAST(e.EmployeeIsActive AS UNSIGNED) AS EmployeeIsActive, + b.BusinessName, u.UserFirstName, u.UserLastName + FROM lt_Users_Businesses_Employees e + JOIN Businesses b ON e.BusinessID = b.BusinessID + JOIN Users u ON e.UserID = u.UserID + WHERE e.BusinessID = ? AND e.UserID = ? + ", [ + { value: businessId, cfsqltype: "cf_sql_integer" }, + { value: userId, cfsqltype: "cf_sql_integer" } + ], { datasource: "payfrit" }); + + if (q.recordCount > 0) { + writeOutput(serializeJSON({ + "OK": true, + "MESSAGE": "Employee updated", + "EMPLOYEE": { + "EmployeeID": q.EmployeeID, + "BusinessID": q.BusinessID, + "BusinessName": q.BusinessName, + "UserID": q.UserID, + "UserName": trim(q.UserFirstName & " " & q.UserLastName), + "StatusID": q.EmployeeStatusID, + "IsActive": q.EmployeeIsActive + } + })); + } else { + writeOutput(serializeJSON({ "OK": false, "ERROR": "not_found", "MESSAGE": "Employee record not found" })); + } +} catch (any e) { + writeOutput(serializeJSON({ "OK": false, "ERROR": "server_error", "MESSAGE": e.message })); +} + diff --git a/api/menu/getForBuilder.cfm b/api/menu/getForBuilder.cfm index 7dfd3e4..67ff349 100644 --- a/api/menu/getForBuilder.cfm +++ b/api/menu/getForBuilder.cfm @@ -217,6 +217,7 @@ try { } // Get all children of templates (options within modifier groups) + // This now includes ALL descendants recursively via a recursive approach // Filter by business and use DISTINCT to avoid duplicates from multiple template links qTemplateChildren = queryExecute(" SELECT DISTINCT @@ -225,7 +226,9 @@ try { c.ItemName, c.ItemPrice, c.ItemIsCheckedByDefault as IsDefault, - c.ItemSortOrder + c.ItemSortOrder, + c.ItemRequiresChildSelection as RequiresSelection, + c.ItemMaxNumSelectionReq as MaxSelections FROM Items c WHERE c.ItemParentItemID IN ( SELECT DISTINCT t.ItemID @@ -238,10 +241,47 @@ try { ORDER BY c.ItemSortOrder, c.ItemName ", { businessID: businessID }, { datasource: "payfrit" }); - // Build lookup of children by template ID + // Build lookup of children by parent ID (flat list for now) + childrenByParent = {}; + allChildIds = []; + for (child in qTemplateChildren) { + parentID = child.ParentItemID; + if (!structKeyExists(childrenByParent, parentID)) { + childrenByParent[parentID] = []; + } + arrayAppend(childrenByParent[parentID], { + "id": "opt_" & child.ItemID, + "dbId": child.ItemID, + "name": child.ItemName, + "price": child.ItemPrice, + "isDefault": child.IsDefault == 1 ? true : false, + "sortOrder": child.ItemSortOrder, + "requiresSelection": isNull(child.RequiresSelection) ? false : (child.RequiresSelection == 1), + "maxSelections": isNull(child.MaxSelections) ? 0 : child.MaxSelections, + "options": [] + }); + arrayAppend(allChildIds, child.ItemID); + } + + // Now recursively attach nested options to their parents + // We need to build the tree structure from the flat list + function attachNestedOptions(items, childrenByParent) { + for (var item in items) { + var itemDbId = item.dbId; + if (structKeyExists(childrenByParent, itemDbId)) { + item.options = childrenByParent[itemDbId]; + // Recursively process children + attachNestedOptions(item.options, childrenByParent); + } + } + } + + // Build the childrenByTemplate using only top-level children (those whose parent is a template) childrenByTemplate = {}; for (child in qTemplateChildren) { parentID = child.ParentItemID; + // Only add to childrenByTemplate if parent is actually a template (in templatesById later) + // For now, just organize by parentID - we'll filter when building templatesById if (!structKeyExists(childrenByTemplate, parentID)) { childrenByTemplate[parentID] = []; } @@ -252,10 +292,17 @@ try { "price": child.ItemPrice, "isDefault": child.IsDefault == 1 ? true : false, "sortOrder": child.ItemSortOrder, - "options": [] + "requiresSelection": isNull(child.RequiresSelection) ? false : (child.RequiresSelection == 1), + "maxSelections": isNull(child.MaxSelections) ? 0 : child.MaxSelections, + "options": structKeyExists(childrenByParent, child.ItemID) ? childrenByParent[child.ItemID] : [] }); } + // Recursively attach deeper nested options + for (templateID in childrenByTemplate) { + attachNestedOptions(childrenByTemplate[templateID], childrenByParent); + } + // Build template lookup with their children templatesById = {}; for (tmpl in qTemplates) { diff --git a/api/menu/saveFromBuilder.cfm b/api/menu/saveFromBuilder.cfm index 550b434..c62b14c 100644 --- a/api/menu/saveFromBuilder.cfm +++ b/api/menu/saveFromBuilder.cfm @@ -7,8 +7,86 @@ response = { "OK": false }; +// Log file for debugging +logFile = expandPath("./saveFromBuilder.log"); + +// Recursive function to save options/modifiers at any depth +function saveOptionsRecursive(options, parentID, businessID, logFile) { + if (!isArray(options) || arrayLen(options) == 0) return; + + var optSortOrder = 0; + for (var opt in options) { + var optDbId = structKeyExists(opt, "dbId") ? val(opt.dbId) : 0; + var requiresSelection = (structKeyExists(opt, "requiresSelection") && opt.requiresSelection) ? 1 : 0; + var maxSelections = structKeyExists(opt, "maxSelections") ? val(opt.maxSelections) : 0; + var isDefault = (structKeyExists(opt, "isDefault") && opt.isDefault) ? 1 : 0; + var optionID = 0; + + fileAppend(logFile, "[#dateTimeFormat(now(), 'yyyy-mm-dd HH:nn:ss')#] Option: #opt.name# (dbId=#optDbId#, parentID=#parentID#, isDefault=#isDefault#, reqSel=#requiresSelection#, maxSel=#maxSelections#)#chr(10)#"); + + if (optDbId > 0) { + optionID = optDbId; + // Update existing option + queryExecute(" + UPDATE Items + SET ItemName = :name, + ItemPrice = :price, + ItemIsCheckedByDefault = :isDefault, + ItemSortOrder = :sortOrder, + ItemRequiresChildSelection = :requiresSelection, + ItemMaxNumSelectionReq = :maxSelections, + ItemParentItemID = :parentID + WHERE ItemID = :optID + ", { + optID: optDbId, + parentID: parentID, + name: opt.name, + price: val(opt.price ?: 0), + isDefault: isDefault, + sortOrder: optSortOrder, + requiresSelection: requiresSelection, + maxSelections: maxSelections + }); + } else { + // Insert new option + queryExecute(" + INSERT INTO Items ( + ItemBusinessID, ItemParentItemID, ItemName, ItemPrice, + ItemIsCheckedByDefault, ItemSortOrder, ItemIsActive, ItemAddedOn, + ItemRequiresChildSelection, ItemMaxNumSelectionReq + ) VALUES ( + :businessID, :parentID, :name, :price, + :isDefault, :sortOrder, 1, NOW(), + :requiresSelection, :maxSelections + ) + ", { + businessID: businessID, + parentID: parentID, + name: opt.name, + price: val(opt.price ?: 0), + isDefault: isDefault, + sortOrder: optSortOrder, + requiresSelection: requiresSelection, + maxSelections: maxSelections + }); + + var result = queryExecute("SELECT LAST_INSERT_ID() as newID"); + optionID = result.newID; + } + + // Recursively save nested options + if (structKeyExists(opt, "options") && isArray(opt.options) && arrayLen(opt.options) > 0) { + saveOptionsRecursive(opt.options, optionID, businessID, logFile); + } + + optSortOrder++; + } +} + try { requestBody = toString(getHttpRequestData().content); + fileAppend(logFile, "[#dateTimeFormat(now(), 'yyyy-mm-dd HH:nn:ss')#] Request received, length: #len(requestBody)##chr(10)#"); + if (!len(requestBody)) { throw("Request body is required"); } @@ -17,6 +95,8 @@ try { businessID = val(jsonData.BusinessID ?: 0); menu = jsonData.Menu ?: {}; + fileAppend(logFile, "[#dateTimeFormat(now(), 'yyyy-mm-dd HH:nn:ss')#] BusinessID: #businessID#, Categories count: #arrayLen(menu.categories ?: [])##chr(10)#"); + if (businessID == 0) { throw("BusinessID is required"); } @@ -25,6 +105,16 @@ try { throw("Menu categories are required"); } + // Log each category and its items + for (cat in menu.categories) { + fileAppend(logFile, "[#dateTimeFormat(now(), 'yyyy-mm-dd HH:nn:ss')#] Category: #cat.name# (dbId=#structKeyExists(cat, 'dbId') ? cat.dbId : 'NEW'#), items: #arrayLen(cat.items ?: [])##chr(10)#"); + if (structKeyExists(cat, "items") && isArray(cat.items)) { + for (item in cat.items) { + fileAppend(logFile, "[#dateTimeFormat(now(), 'yyyy-mm-dd HH:nn:ss')#] - Item: #item.name# (dbId=#structKeyExists(item, 'dbId') ? item.dbId : 'NEW'#) price=#item.price ?: 0##chr(10)#"); + } + } + } + // Check if new schema is active (ItemBusinessID column exists and has data) newSchemaActive = false; try { @@ -239,6 +329,12 @@ try { requiresSelection: requiresSelection, maxSelections: maxSelections }); + + // Save the template's options (children) recursively + if (structKeyExists(mod, "options") && isArray(mod.options)) { + fileAppend(logFile, "[#dateTimeFormat(now(), 'yyyy-mm-dd HH:nn:ss')#] Template: #mod.name# (dbId=#modDbId#) has #arrayLen(mod.options)# options#chr(10)#"); + saveOptionsRecursive(mod.options, modDbId, businessID, logFile); + } } else if (modDbId > 0) { // Update existing direct modifier queryExecute(" @@ -259,6 +355,12 @@ try { requiresSelection: requiresSelection, maxSelections: maxSelections }); + + // Save nested options recursively + if (structKeyExists(mod, "options") && isArray(mod.options)) { + fileAppend(logFile, "[#dateTimeFormat(now(), 'yyyy-mm-dd HH:nn:ss')#] Modifier: #mod.name# (dbId=#modDbId#) has #arrayLen(mod.options)# options#chr(10)#"); + saveOptionsRecursive(mod.options, modDbId, businessID, logFile); + } } else { // Insert new direct modifier (non-template) queryExecute(" @@ -281,6 +383,14 @@ try { requiresSelection: requiresSelection, maxSelections: maxSelections }); + + // Get the new modifier's ID and save nested options + modResult = queryExecute("SELECT LAST_INSERT_ID() as newModID"); + newModID = modResult.newModID; + if (structKeyExists(mod, "options") && isArray(mod.options)) { + fileAppend(logFile, "[#dateTimeFormat(now(), 'yyyy-mm-dd HH:nn:ss')#] New Modifier: #mod.name# (newId=#newModID#) has #arrayLen(mod.options)# options#chr(10)#"); + saveOptionsRecursive(mod.options, newModID, businessID, logFile); + } } modSortOrder++; } diff --git a/api/orders/getDetail.cfm b/api/orders/getDetail.cfm index 100b861..0bd7be8 100644 --- a/api/orders/getDetail.cfm +++ b/api/orders/getDetail.cfm @@ -1,13 +1,12 @@ - /** * Get Order Detail - * Returns full order info including line items and customer details + * Returns full order info including line items, customer details, and staff who worked on the order * * GET: ?OrderID=123 * POST: { OrderID: 123 } @@ -55,13 +54,15 @@ try { o.OrderAddedOn, o.OrderLastEditedOn, o.OrderSubmittedOn, + o.OrderTipAmount, u.UserFirstName, u.UserLastName, u.UserContactNumber, u.UserEmailAddress, sp.ServicePointName, sp.ServicePointTypeID, - b.BusinessName + b.BusinessName, + b.BusinessTaxRate FROM Orders o LEFT JOIN Users u ON u.UserID = o.OrderUserID LEFT JOIN ServicePoints sp ON sp.ServicePointID = o.OrderServicePointID @@ -140,29 +141,35 @@ try { subtotal += itemTotal; } - // Calculate tax (assume 8.75% if not stored) - taxRate = 0.0875; + // Calculate tax using business tax rate or default 8.25% + taxRate = isNumeric(qOrder.BusinessTaxRate) && qOrder.BusinessTaxRate > 0 ? qOrder.BusinessTaxRate : 0.0825; tax = subtotal * taxRate; - // Look up tip from Payments table if exists - tip = 0; - try { - qPayment = queryExecute(" - SELECT PaymentTipAmount - FROM Payments - WHERE PaymentOrderID = :orderID - LIMIT 1 - ", { orderID: orderID }); - if (qPayment.recordCount > 0 && !isNull(qPayment.PaymentTipAmount)) { - tip = qPayment.PaymentTipAmount; - } - } catch (any e) { - // Payments table may not exist or have this column, ignore - } + // Get tip from order + tip = isNumeric(qOrder.OrderTipAmount) ? qOrder.OrderTipAmount : 0; // Calculate total total = subtotal + tax + tip; + // Get staff who worked on this order (from Tasks table) + qStaff = queryExecute(" + SELECT DISTINCT u.UserID, u.UserFirstName + FROM Tasks t + INNER JOIN Users u ON u.UserID = t.TaskClaimedByUserID + WHERE t.TaskOrderID = :orderID + AND t.TaskClaimedByUserID > 0 + ", { orderID: orderID }); + + // Build staff array with avatar URLs + staff = []; + for (row in qStaff) { + arrayAppend(staff, { + "UserID": row.UserID, + "FirstName": row.UserFirstName, + "AvatarUrl": "https://biz.payfrit.com/uploads/users/" & row.UserID & ".jpg" + }); + } + // Build response order = { "OrderID": qOrder.OrderID, @@ -192,7 +199,8 @@ try { "Name": qOrder.ServicePointName, "TypeID": qOrder.ServicePointTypeID }, - "LineItems": lineItems + "LineItems": lineItems, + "Staff": staff }; response["OK"] = true; @@ -212,8 +220,10 @@ function getStatusText(status) { case 1: return "Submitted"; case 2: return "In Progress"; case 3: return "Ready"; - case 4: return "Completed"; - case 5: return "Cancelled"; + case 4: return "On the Way"; + case 5: return "Complete"; + case 6: return "Cancelled"; + case 7: return "Deleted"; default: return "Unknown"; } } diff --git a/api/orders/listForKDS.cfm b/api/orders/listForKDS.cfm index 235ec49..f3bc1f4 100644 --- a/api/orders/listForKDS.cfm +++ b/api/orders/listForKDS.cfm @@ -125,9 +125,11 @@ i.ItemName, i.ItemParentItemID, i.ItemIsCheckedByDefault, - i.ItemStationID + i.ItemStationID, + parent.ItemName AS ItemParentName FROM OrderLineItems oli INNER JOIN Items i ON i.ItemID = oli.OrderLineItemItemID + LEFT JOIN Items parent ON parent.ItemID = i.ItemParentItemID WHERE oli.OrderLineItemOrderID = ? AND oli.OrderLineItemIsDeleted = b'0' AND (i.ItemStationID = ? OR i.ItemStationID = 0 OR i.ItemStationID IS NULL OR oli.OrderLineItemParentOrderLineItemID > 0) @@ -149,9 +151,11 @@ i.ItemName, i.ItemParentItemID, i.ItemIsCheckedByDefault, - i.ItemStationID + i.ItemStationID, + parent.ItemName AS ItemParentName FROM OrderLineItems oli INNER JOIN Items i ON i.ItemID = oli.OrderLineItemItemID + LEFT JOIN Items parent ON parent.ItemID = i.ItemParentItemID WHERE oli.OrderLineItemOrderID = ? AND oli.OrderLineItemIsDeleted = b'0' ORDER BY oli.OrderLineItemID @@ -169,6 +173,7 @@ "OrderLineItemRemark": qLineItems.OrderLineItemRemark, "ItemName": qLineItems.ItemName, "ItemParentItemID": qLineItems.ItemParentItemID, + "ItemParentName": qLineItems.ItemParentName, "ItemIsCheckedByDefault": qLineItems.ItemIsCheckedByDefault, "ItemStationID": qLineItems.ItemStationID })> diff --git a/api/orders/setLineItem.cfm b/api/orders/setLineItem.cfm index 108da40..8f7b927 100644 --- a/api/orders/setLineItem.cfm +++ b/api/orders/setLineItem.cfm @@ -57,77 +57,116 @@ { datasource = "payfrit" } )> + + + + + + + + + + + - - direct child: ItemID=#qKids.ItemID#")> + + + + + + template child: ItemID=#qTemplateKids.ItemID#")> + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + @@ -137,23 +176,25 @@ + + + + + + + @@ -331,8 +380,12 @@ { datasource = "payfrit" } )> + + + + + + + + + - + + + + + + - - + + + + + + + + + + + + + diff --git a/api/orders/setOrderType.cfm b/api/orders/setOrderType.cfm index 823d20f..8d66bdb 100644 --- a/api/orders/setOrderType.cfm +++ b/api/orders/setOrderType.cfm @@ -146,12 +146,12 @@ })> - - + + @@ -201,28 +201,48 @@ - + + + + + + - - + + + + + + + - + + + @@ -73,23 +95,60 @@ ", [ { value = OrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })> + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/api/stripe/webhook.cfm b/api/stripe/webhook.cfm index b9b2b47..e12097d 100644 --- a/api/stripe/webhook.cfm +++ b/api/stripe/webhook.cfm @@ -48,7 +48,8 @@ try { orderID = val(eventData.metadata.order_id ?: 0); if (orderID > 0) { - // Update order status to paid/submitted + // Update order status to paid/submitted (status 1) + // Note: Task is created later when order status changes to Ready (3) in updateStatus.cfm queryExecute(" UPDATE Orders SET OrderPaymentStatus = 'paid', @@ -57,15 +58,6 @@ try { WHERE OrderID = :orderID ", { orderID: orderID }); - // Create a task for the new order - queryExecute(" - INSERT INTO Tasks (TaskBusinessID, TaskCategoryID, TaskTitle, TaskCreatedOn, TaskStatusID, TaskSourceType, TaskSourceID) - SELECT o.OrderBusinessID, 1, CONCAT('Order #', o.OrderID), NOW(), 0, 'order', o.OrderID - FROM Orders o - WHERE o.OrderID = :orderID - AND NOT EXISTS (SELECT 1 FROM Tasks t WHERE t.TaskSourceType = 'order' AND t.TaskSourceID = :orderID) - ", { orderID: orderID }); - writeLog(file="stripe_webhooks", text="Order #orderID# marked as paid"); } break; diff --git a/api/tasks/accept.cfm b/api/tasks/accept.cfm index cc1928e..c77c303 100644 --- a/api/tasks/accept.cfm +++ b/api/tasks/accept.cfm @@ -37,7 +37,7 @@ @@ -62,6 +62,17 @@ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })> + + + + + - + diff --git a/kds/index.html b/kds/index.html index 201265d..f14edbf 100644 --- a/kds/index.html +++ b/kds/index.html @@ -104,6 +104,6 @@ - + \ No newline at end of file diff --git a/kds/kds.js b/kds/kds.js index 396df7b..4fbbfd8 100644 --- a/kds/kds.js +++ b/kds/kds.js @@ -247,6 +247,7 @@ function updateStatus(isConnected, message) { // 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 = `
@@ -259,6 +260,14 @@ function renderOrders() { `; 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.ItemName} (ID=${li.OrderLineItemID}, ParentID=${li.OrderLineItemParentOrderLineItemID})`); + }); + } + }); grid.innerHTML = orders.map(order => renderOrder(order)).join(''); } @@ -301,6 +310,7 @@ function renderOrder(order) { // Render line item with modifiers function renderLineItem(item, allItems) { const modifiers = allItems.filter(mod => mod.OrderLineItemParentOrderLineItemID === item.OrderLineItemID); + console.log(`Item: ${item.ItemName} (ID: ${item.OrderLineItemID}) has ${modifiers.length} direct modifiers:`, modifiers.map(m => m.ItemName)); return `
@@ -330,24 +340,34 @@ function renderAllModifiers(modifiers, allItems) { } const leafModifiers = []; - function collectLeafModifiers(mods) { + function collectLeafModifiers(mods, depth = 0) { + console.log(` collectLeafModifiers depth=${depth}, processing ${mods.length} mods:`, mods.map(m => m.ItemName)); 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); + console.log(` Mod: ${mod.ItemName} (ID: ${mod.OrderLineItemID}) has ${children.length} children`); if (children.length === 0) { // This is a leaf node (actual selection) - leafModifiers.push({ mod, path: getModifierPath(mod) }); + const path = getModifierPath(mod); + console.log(` -> LEAF, path: ${path.join(' > ')}`); + leafModifiers.push({ mod, path }); } else { // Has children, recurse deeper - collectLeafModifiers(children); + collectLeafModifiers(children, depth + 1); } }); } collectLeafModifiers(modifiers); - leafModifiers.forEach(({ path }) => { - html += `
+ ${escapeHtml(path.join(': '))}
`; + console.log(` Total leaf modifiers found: ${leafModifiers.length}`); + leafModifiers.forEach(({ mod }) => { + // Use ItemParentName (the category/template name) if available, otherwise just show the item name + // This gives us "Drink Choice: Coke" instead of "Double Double Combo: Coke" + const displayText = mod.ItemParentName + ? `${mod.ItemParentName}: ${mod.ItemName}` + : mod.ItemName; + html += `
+ ${escapeHtml(displayText)}
`; }); html += '
'; diff --git a/portal/menu-builder.html b/portal/menu-builder.html index 84da046..7fa9de4 100644 --- a/portal/menu-builder.html +++ b/portal/menu-builder.html @@ -1722,7 +1722,8 @@ for (const item of cat.items) { if (item.id === parentId) { const opt = item.modifiers.find(m => m.id === optionId); - if (opt) return { option: opt, parent: item, parentType: 'item', category: cat }; + // rootItem is the item itself when parent is the item + if (opt) return { option: opt, parent: item, parentType: 'item', category: cat, rootItem: item }; } // Check nested modifiers const result = this.findInModifiers(item.modifiers, parentId, optionId); @@ -1758,18 +1759,49 @@ const result = this.findOptionWithParent(parentId, optionId); if (result) { - this.selectedData = { type: 'option', data: result.option, parent: result.parent, depth: depth }; - this.showPropertiesForOption(result.option, result.parent, depth); + // Store rootItem for detach functionality + this.selectedData = { + type: 'option', + data: result.option, + parent: result.parent, + depth: depth, + rootItem: result.rootItem || result.category?.items?.find(i => i.id === parentId) + }; + this.showPropertiesForOption(result.option, result.parent, depth, result.rootItem); } }, // Show properties for option at any depth - showPropertiesForOption(option, parent, depth) { + showPropertiesForOption(option, parent, depth, rootItem) { const hasOptions = option.options && option.options.length > 0; const levelName = depth === 1 ? 'Modifier Group' : (depth === 2 ? 'Option' : `Level ${depth} Option`); const isModifierGroup = depth === 1 && hasOptions; + // Check if this is a shared template (only for depth 1 modifiers) + const isShared = depth === 1 && option.dbId && this.isSharedTemplate(option.dbId); + const sharedItems = isShared ? this.getItemsSharingTemplate(option.dbId) : []; + document.getElementById('propertiesContent').innerHTML = ` + ${isShared ? ` +
+
+ 🔗 + Shared Template +
+

+ Changes will apply to all ${sharedItems.length} items using this modifier: +

+

+ ${sharedItems.map(n => this.escapeHtml(n)).join(', ')} +

+ ${rootItem ? ` + + ` : ''} +
+ ` : ''}
o.dbId === optionDbId); + if (opt) { + opt[field] = value; + updateCount++; + console.log('[MenuBuilder] Updated nested option in item:', item.name); + } + } + // Also check nested options recursively for deeper levels + this.updateOptionInModifiers(mod.options || [], parentDbId, optionDbId, field, value); + } + } + } + console.log('[MenuBuilder] Total copies updated:', updateCount); + + this.render(); + this.selectOption(parentId, optionId, this.selectedData?.depth || 1); + }, + + // Helper to recursively update option in nested modifiers + updateOptionInModifiers(options, parentDbId, optionDbId, field, value) { + for (const opt of options) { + if (opt.dbId === parentDbId) { + const child = (opt.options || []).find(o => o.dbId === optionDbId); + if (child) { + child[field] = value; + } + } + if (opt.options && opt.options.length > 0) { + this.updateOptionInModifiers(opt.options, parentDbId, optionDbId, field, value); + } + } + }, + + // Check if a modifier (by dbId) is shared across multiple items + isSharedTemplate(modifierDbId) { + if (!modifierDbId) return false; + + let count = 0; + for (const cat of this.menu.categories) { + for (const item of cat.items) { + for (const mod of (item.modifiers || [])) { + if (mod.dbId === modifierDbId) { + count++; + if (count > 1) return true; + } + } + } + } + return false; + }, + + // Get list of items that share a template + getItemsSharingTemplate(modifierDbId) { + if (!modifierDbId) return []; + + const items = []; + for (const cat of this.menu.categories) { + for (const item of cat.items) { + for (const mod of (item.modifiers || [])) { + if (mod.dbId === modifierDbId) { + items.push(item.name); + } + } + } + } + return items; + }, + + // Detach a modifier from its template - creates a local copy for this item only + detachFromTemplate(itemId, modifierId) { + this.saveState(); + + // Find the item and modifier + for (const cat of this.menu.categories) { + const item = cat.items.find(i => i.id === itemId); + if (item) { + const modIndex = item.modifiers.findIndex(m => m.id === modifierId); + if (modIndex !== -1) { + const mod = item.modifiers[modIndex]; + + // Create a deep copy with new IDs (null dbId means it will be created as new) + const detachedCopy = this.deepCopyWithNewIds(mod); + detachedCopy.name = mod.name + ' (Custom)'; + + // Replace the original with the detached copy + item.modifiers[modIndex] = detachedCopy; + + this.render(); + this.toast('Modifier detached - this item now has its own copy', 'success'); + + // Select the new detached modifier + this.selectOption(itemId, detachedCopy.id, 1); + return; + } + } + } + + this.toast('Could not find modifier to detach', 'error'); + }, + + // Create a deep copy of a modifier/option tree with new IDs + deepCopyWithNewIds(obj) { + const newObj = { + id: 'mod_new_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9), + dbId: null, // null = will be created as new in database + name: obj.name, + price: obj.price || 0, + isDefault: obj.isDefault || false, + sortOrder: obj.sortOrder || 0, + requiresSelection: obj.requiresSelection || false, + maxSelections: obj.maxSelections || 0, + options: [] + }; + + // Recursively copy options + if (obj.options && obj.options.length > 0) { + newObj.options = obj.options.map(opt => this.deepCopyWithNewIds(opt)); + } + + return newObj; }, // Delete option at any depth @@ -2489,23 +2664,59 @@ // Save menu to API async saveMenu() { try { + console.log('[MenuBuilder] Saving menu...'); + console.log('[MenuBuilder] BusinessID:', this.config.businessId); + + // Debug: Find and log the specific option we're testing + for (const cat of this.menu.categories) { + for (const item of cat.items) { + for (const mod of (item.modifiers || [])) { + if (mod.name === 'Select Drink') { + console.log('[MenuBuilder] Select Drink modifier at save time:', JSON.stringify(mod, null, 2)); + } + } + } + } + console.log('[MenuBuilder] Menu data:', JSON.stringify(this.menu, null, 2)); + + const payload = { + BusinessID: this.config.businessId, + Menu: this.menu + }; + console.log('[MenuBuilder] Full payload:', JSON.stringify(payload, null, 2)); + const response = await fetch(`${this.config.apiBaseUrl}/menu/saveFromBuilder.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - BusinessID: this.config.businessId, - Menu: this.menu - }) + body: JSON.stringify(payload) }); - const data = await response.json(); + + console.log('[MenuBuilder] Response status:', response.status); + const responseText = await response.text(); + console.log('[MenuBuilder] Raw response:', responseText); + + let data; + try { + data = JSON.parse(responseText); + } catch (parseErr) { + console.error('[MenuBuilder] Failed to parse response as JSON:', parseErr); + this.toast('Server returned invalid response', 'error'); + return; + } + + console.log('[MenuBuilder] Parsed response:', data); + if (data.OK) { this.toast('Menu saved successfully!', 'success'); + // Reload menu to get updated dbIds + await this.loadMenu(); } else { + console.error('[MenuBuilder] Save failed:', data.ERROR, data.DETAIL); this.toast(data.ERROR || 'Failed to save', 'error'); } } catch (err) { console.error('[MenuBuilder] Save error:', err); - this.toast('Error saving menu', 'error'); + this.toast('Error saving menu: ' + err.message, 'error'); } },