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 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-12 18:45:06 -08:00
parent e20aede25f
commit 0a10380639
18 changed files with 965 additions and 179 deletions

View file

@ -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;

22
api/admin/clearCarts.cfm Normal file
View file

@ -0,0 +1,22 @@
<cfsetting showdebugoutput="false">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
// 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"
}));
</cfscript>

View file

@ -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("

View file

@ -0,0 +1,70 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
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
}));
</cfscript>

View file

@ -0,0 +1,67 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
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 }));
}
</cfscript>

View file

@ -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) {

View file

@ -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++;
}

View file

@ -1,13 +1,12 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<!--- Force recompile: 2026-01-09 --->
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* 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";
}
}

View file

@ -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
})>

View file

@ -57,7 +57,47 @@
{ datasource = "payfrit" }
)>
<!--- Also find default children from templates linked to this item --->
<cfset var qTemplateKids = queryExecute(
"
SELECT i.ItemID, i.ItemPrice
FROM ItemTemplateLinks tl
INNER JOIN Items i ON i.ItemParentItemID = tl.TemplateItemID
WHERE tl.ItemID = ?
AND i.ItemIsCheckedByDefault = 1
AND i.ItemIsActive = 1
ORDER BY i.ItemSortOrder, i.ItemID
",
[ { value = arguments.ParentItemID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<!--- Store debug info in request scope for response --->
<cfif NOT structKeyExists(request, "attachDebug")>
<cfset request.attachDebug = []>
</cfif>
<cfset arrayAppend(request.attachDebug, "attachDefaultChildren: OrderID=#arguments.OrderID#, ParentLI=#arguments.ParentLineItemID#, ParentItemID=#arguments.ParentItemID#")>
<cfset arrayAppend(request.attachDebug, " qKids=#qKids.recordCount# rows, qTemplateKids=#qTemplateKids.recordCount# rows")>
<!--- Process direct children --->
<cfloop query="qKids">
<cfset arrayAppend(request.attachDebug, " -> direct child: ItemID=#qKids.ItemID#")>
<cfset processDefaultChild(arguments.OrderID, arguments.ParentLineItemID, qKids.ItemID, qKids.ItemPrice)>
</cfloop>
<!--- Process template children --->
<cfloop query="qTemplateKids">
<cfset arrayAppend(request.attachDebug, " -> template child: ItemID=#qTemplateKids.ItemID#")>
<cfset processDefaultChild(arguments.OrderID, arguments.ParentLineItemID, qTemplateKids.ItemID, qTemplateKids.ItemPrice)>
</cfloop>
</cffunction>
<cffunction name="processDefaultChild" access="public" returntype="void" output="false">
<cfargument name="OrderID" type="numeric" required="true">
<cfargument name="ParentLineItemID" type="numeric" required="true">
<cfargument name="ItemID" type="numeric" required="true">
<cfargument name="ItemPrice" type="numeric" required="true">
<!--- If existing, undelete; else insert new --->
<cfset var qExisting = queryExecute(
"
@ -71,7 +111,7 @@
[
{ value = arguments.OrderID, cfsqltype = "cf_sql_integer" },
{ value = arguments.ParentLineItemID, cfsqltype = "cf_sql_integer" },
{ value = qKids.ItemID, cfsqltype = "cf_sql_integer" }
{ value = arguments.ItemID, cfsqltype = "cf_sql_integer" }
],
{ datasource = "payfrit" }
)>
@ -86,7 +126,7 @@
[ { value = qExisting.OrderLineItemID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<cfset attachDefaultChildren(arguments.OrderID, qExisting.OrderLineItemID, qKids.ItemID)>
<cfset attachDefaultChildren(arguments.OrderID, qExisting.OrderLineItemID, arguments.ItemID)>
<cfelse>
<cfset var NewLIID = nextId("OrderLineItems","OrderLineItemID")>
<cfset queryExecute(
@ -119,15 +159,14 @@
{ value = NewLIID, cfsqltype = "cf_sql_integer" },
{ value = arguments.ParentLineItemID, cfsqltype = "cf_sql_integer" },
{ value = arguments.OrderID, cfsqltype = "cf_sql_integer" },
{ value = qKids.ItemID, cfsqltype = "cf_sql_integer" },
{ value = qKids.ItemPrice, cfsqltype = "cf_sql_decimal" },
{ value = arguments.ItemID, cfsqltype = "cf_sql_integer" },
{ value = arguments.ItemPrice, cfsqltype = "cf_sql_decimal" },
{ value = now(), cfsqltype = "cf_sql_timestamp" }
],
{ datasource = "payfrit" }
)>
<cfset attachDefaultChildren(arguments.OrderID, NewLIID, qKids.ItemID)>
<cfset attachDefaultChildren(arguments.OrderID, NewLIID, arguments.ItemID)>
</cfif>
</cfloop>
</cffunction>
<cffunction name="loadCartPayload" access="public" returntype="struct" output="false">
@ -137,23 +176,25 @@
<cfset var qOrder = queryExecute(
"
SELECT
OrderID,
OrderUUID,
OrderUserID,
OrderBusinessID,
OrderBusinessDeliveryMultiplier,
OrderTypeID,
OrderDeliveryFee,
OrderStatusID,
OrderAddressID,
OrderPaymentID,
OrderRemarks,
OrderAddedOn,
OrderLastEditedOn,
OrderSubmittedOn,
OrderServicePointID
FROM Orders
WHERE OrderID = ?
o.OrderID,
o.OrderUUID,
o.OrderUserID,
o.OrderBusinessID,
o.OrderBusinessDeliveryMultiplier,
o.OrderTypeID,
o.OrderDeliveryFee,
o.OrderStatusID,
o.OrderAddressID,
o.OrderPaymentID,
o.OrderRemarks,
o.OrderAddedOn,
o.OrderLastEditedOn,
o.OrderSubmittedOn,
o.OrderServicePointID,
COALESCE(b.BusinessDeliveryFlatFee, 0) AS BusinessDeliveryFee
FROM Orders o
LEFT JOIN Businesses b ON b.BusinessID = o.OrderBusinessID
WHERE o.OrderID = ?
LIMIT 1
",
[ { value = arguments.OrderID, cfsqltype = "cf_sql_integer" } ],
@ -179,7 +220,8 @@
"OrderAddedOn": qOrder.OrderAddedOn,
"OrderLastEditedOn": qOrder.OrderLastEditedOn,
"OrderSubmittedOn": qOrder.OrderSubmittedOn,
"OrderServicePointID": qOrder.OrderServicePointID
"OrderServicePointID": qOrder.OrderServicePointID,
"BusinessDeliveryFee": qOrder.BusinessDeliveryFee
}>
<cfset var qLI = queryExecute(
@ -227,6 +269,13 @@
<cfset data = readJsonBody()>
<!--- Debug logging --->
<cfset logFile = "c:/lucee/tomcat/webapps/ROOT/biz.payfrit.com/api/orders/setLineItem.log">
<cftry>
<cfset fileAppend(logFile, "[#dateTimeFormat(now(), 'yyyy-mm-dd HH:nn:ss')#] Request received: #serializeJSON(data)##chr(10)#")>
<cfcatch><!--- ignore log errors ---></cfcatch>
</cftry>
<cfset OrderID = val( structKeyExists(data,"OrderID") ? data.OrderID : 0 )>
<cfset ParentLineItemID = val( structKeyExists(data,"ParentOrderLineItemID") ? data.ParentOrderLineItemID : 0 )>
<cfset ItemID = val( structKeyExists(data,"ItemID") ? data.ItemID : 0 )>
@ -331,8 +380,12 @@
{ datasource = "payfrit" }
)>
<!--- Initialize debug array at start of processing --->
<cfset request.attachDebug = ["Flow start: qExisting.recordCount=#qExisting.recordCount#, IsSelected=#IsSelected#"]>
<cfif qExisting.recordCount GT 0>
<!--- Update existing --->
<cfset arrayAppend(request.attachDebug, "Path: update existing")>
<cfif IsSelected>
<cfset queryExecute(
"
@ -355,7 +408,12 @@
)>
<!--- Attach default children for this node (recursively) --->
<cfif NOT structKeyExists(request, "attachDebug")>
<cfset request.attachDebug = []>
</cfif>
<cfset arrayAppend(request.attachDebug, "BEFORE attachDefaultChildren call: OrderID=#OrderID#, LIID=#qExisting.OrderLineItemID#, ItemID=#ItemID#")>
<cfset attachDefaultChildren(OrderID, qExisting.OrderLineItemID, ItemID)>
<cfset arrayAppend(request.attachDebug, "AFTER attachDefaultChildren call")>
<cfelse>
<cfset queryExecute(
"
@ -416,6 +474,7 @@
</cfif>
<!--- Touch order last edited --->
<cftry>
<cfset queryExecute(
"UPDATE Orders SET OrderLastEditedOn = ? WHERE OrderID = ?",
[
@ -424,16 +483,41 @@
],
{ datasource = "payfrit" }
)>
<cfset payload = loadCartPayload(OrderID)>
<cfset apiAbort(payload)>
<cfcatch>
<cfset apiAbort({
"OK": false,
"ERROR": "update_order_error",
"MESSAGE": "Error updating order timestamp: " & cfcatch.message,
"DETAIL": cfcatch.tagContext[1].template & ":" & cfcatch.tagContext[1].line
})>
</cfcatch>
</cftry>
<cftry>
<cfset payload = loadCartPayload(OrderID)>
<!--- Add debug info to response --->
<cfif structKeyExists(request, "attachDebug")>
<cfset payload["DEBUG_ATTACH"] = request.attachDebug>
</cfif>
<cfset apiAbort(payload)>
<cfcatch>
<cfset fileAppend(logFile, "[#dateTimeFormat(now(), 'yyyy-mm-dd HH:nn:ss')#] ERROR in loadCartPayload: #cfcatch.message##chr(10)#")>
<cfset apiAbort({
"OK": false,
"ERROR": "load_cart_error",
"MESSAGE": "Error loading cart: " & cfcatch.message & " | " & (structKeyExists(cfcatch, "detail") ? cfcatch.detail : ""),
"DETAIL": cfcatch.tagContext[1].template & ":" & cfcatch.tagContext[1].line
})>
</cfcatch>
</cftry>
<cfcatch>
<cfset fileAppend(logFile, "[#dateTimeFormat(now(), 'yyyy-mm-dd HH:nn:ss')#] OUTER ERROR: #cfcatch.message# | Detail: #(structKeyExists(cfcatch, 'detail') ? cfcatch.detail : '')# | SQL: #(structKeyExists(cfcatch, 'sql') ? cfcatch.sql : '')##chr(10)#")>
<cfset apiAbort({
"OK": false,
"ERROR": "server_error",
"MESSAGE": "DB error setting line item",
"DETAIL": cfcatch.message
"MESSAGE": "DB error setting line item: " & cfcatch.message & " | " & (structKeyExists(cfcatch, "detail") ? cfcatch.detail : "") & " | " & (structKeyExists(cfcatch, "sql") ? cfcatch.sql : ""),
"DETAIL": cfcatch.tagContext[1].template & ":" & cfcatch.tagContext[1].line
})>
</cfcatch>
</cftry>

View file

@ -146,12 +146,12 @@
})>
</cfif>
<!--- OrderTypeID: 2=takeaway, 3=delivery --->
<cfif OrderTypeID LT 2 OR OrderTypeID GT 3>
<!--- OrderTypeID: 1=dine-in, 2=takeaway, 3=delivery --->
<cfif OrderTypeID LT 1 OR OrderTypeID GT 3>
<cfset apiAbort({
"OK": false,
"ERROR": "invalid_order_type",
"MESSAGE": "OrderTypeID must be 2 (takeaway) or 3 (delivery).",
"MESSAGE": "OrderTypeID must be 1 (dine-in), 2 (takeaway), or 3 (delivery).",
"DETAIL": ""
})>
</cfif>
@ -201,6 +201,7 @@
</cfif>
<!--- Update order type and address --->
<cftry>
<cfset queryExecute(
"
UPDATE Orders
@ -219,10 +220,29 @@
],
{ datasource = "payfrit" }
)>
<cfcatch>
<cfset apiAbort({
"OK": false,
"ERROR": "update_error",
"MESSAGE": "Failed to update order type",
"DETAIL": cfcatch.message
})>
</cfcatch>
</cftry>
<!--- Return updated cart --->
<cftry>
<cfset payload = loadCartPayload(OrderID)>
<cfset apiAbort(payload)>
<cfcatch>
<cfset apiAbort({
"OK": false,
"ERROR": "load_error",
"MESSAGE": "Failed to load updated cart",
"DETAIL": cfcatch.message
})>
</cfcatch>
</cftry>
<cfcatch>
<cfset apiAbort({

View file

@ -63,7 +63,29 @@
{ value = OrderID, cfsqltype = "cf_sql_integer" }
], { datasource = "payfrit" })>
<!--- Create delivery task when order is marked as Ready (status 3) --->
<!---
Order Status Flow:
0 = Cart
1 = Submitted (paid via Stripe/other payment, or added to tab)
2 = In Progress (Kitchen preparing)
3 = Order Complete, Final Prep (Kitchen done, create delivery/pickup task)
4 = Claimed (Worker claimed task - delivering to table, out for delivery, or notifying customer for pickup)
5 = Delivered/Complete
6 = Cancelled
7 = Deleted (user abandoned cart and started new one)
Task Types (auto-created at status 3):
- Dine-in: "Deliver Order #X to <Service Point>"
- Takeaway: "Prepare Order #X for customer pickup"
- Delivery: "Prepare Order #X for delivery worker pickup"
Other system tasks (manually created or scheduled):
- "Go to <Service Point>" (customer call server)
- "Customer Chat"
- Scheduled: "Clean Men's Washroom", "Clean Women's Restroom", "Silverware roll-ups", "Check Floor", "Check garbage cans", etc.
--->
<!--- Create delivery/pickup task when order moves to status 3 (Final Prep) --->
<cfset taskCreated = false>
<cfif NewStatusID EQ 3 AND oldStatusID NEQ 3>
<cftry>
@ -73,23 +95,60 @@
", [ { value = OrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
<cfif qExisting.recordCount EQ 0>
<!--- Get order type and address info --->
<cfset qOrderDetails = queryExecute("
SELECT o.OrderTypeID, a.AddressLine1, a.AddressCity
FROM Orders o
LEFT JOIN Addresses a ON a.AddressID = o.OrderAddressID
WHERE o.OrderID = ?
", [ { value = OrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
<cfset orderTypeID = qOrderDetails.recordCount GT 0 ? val(qOrderDetails.OrderTypeID) : 1>
<!--- Determine task title based on order type --->
<!--- OrderTypeID: 1=dine-in, 2=takeaway, 3=delivery --->
<cfswitch expression="#orderTypeID#">
<cfcase value="2">
<!--- Takeaway: Staff prepares for customer pickup --->
<cfset taskTitle = "Prepare Order ###OrderID# for customer pickup">
<cfset taskCategoryID = 2>
</cfcase>
<cfcase value="3">
<!--- Delivery: Staff prepares for delivery driver pickup --->
<cfset taskTitle = "Prepare Order ###OrderID# for delivery worker pickup">
<cfset taskCategoryID = 1>
</cfcase>
<cfdefaultcase>
<!--- Dine-in: Server delivers to service point --->
<cfset tableName = len(qOrder.ServicePointName) ? qOrder.ServicePointName : "Table">
<cfset taskTitle = "Deliver Order ###OrderID# to " & tableName>
<cfset taskCategoryID = 3>
</cfdefaultcase>
</cfswitch>
<cfset queryExecute("
INSERT INTO Tasks (
TaskBusinessID,
TaskOrderID,
TaskTypeID,
TaskCategoryID,
TaskTitle,
TaskClaimedByUserID,
TaskAddedOn
) VALUES (
?,
?,
1,
?,
?,
0,
NOW()
)
", [
{ value = qOrder.OrderBusinessID, cfsqltype = "cf_sql_integer" },
{ value = OrderID, cfsqltype = "cf_sql_integer" }
{ value = OrderID, cfsqltype = "cf_sql_integer" },
{ value = taskCategoryID, cfsqltype = "cf_sql_integer" },
{ value = taskTitle, cfsqltype = "cf_sql_varchar" }
], { datasource = "payfrit" })>
<cfset taskCreated = true>
</cfif>

View file

@ -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;

View file

@ -37,7 +37,7 @@
<cftry>
<!--- Verify task exists and is unclaimed --->
<cfset qTask = queryExecute("
SELECT TaskID, TaskClaimedByUserID, TaskBusinessID
SELECT TaskID, TaskClaimedByUserID, TaskBusinessID, TaskOrderID
FROM Tasks
WHERE TaskID = ?
", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
@ -62,6 +62,17 @@
{ value = TaskID, cfsqltype = "cf_sql_integer" }
], { datasource = "payfrit" })>
<!--- If task has an associated order, update order status to 4 (Claimed) --->
<cfif val(qTask.TaskOrderID) GT 0>
<cfset queryExecute("
UPDATE Orders
SET OrderStatusID = 4,
OrderLastEditedOn = NOW()
WHERE OrderID = ?
AND OrderStatusID = 3
", [ { value = qTask.TaskOrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
</cfif>
<cfset apiAbort({
"OK": true,
"ERROR": "",

View file

@ -70,12 +70,12 @@
WHERE TaskID = ?
", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
<!--- If task has an associated order, mark it as Completed (status 4) --->
<!--- If task has an associated order, mark it as Delivered/Complete (status 5) --->
<cfset orderUpdated = false>
<cfif qTask.TaskOrderID GT 0>
<cfset queryExecute("
UPDATE Orders
SET OrderStatusID = 4,
SET OrderStatusID = 5,
OrderLastEditedOn = NOW()
WHERE OrderID = ?
", [ { value = qTask.TaskOrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>

View file

@ -104,6 +104,6 @@
</div>
</div>
<script src="kds.js"></script>
<script src="kds.js?v=2"></script>
</body>
</html>

View file

@ -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 = `
<div class="empty-state">
@ -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 `
<div class="line-item">
<div class="line-item-main">
@ -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 += `<div class="modifier">+ ${escapeHtml(path.join(': '))}</div>`;
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 += `<div class="modifier">+ ${escapeHtml(displayText)}</div>`;
});
html += '</div>';

View file

@ -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 ? `
<div style="background: rgba(255, 193, 7, 0.1); border: 1px solid var(--warning); border-radius: 8px; padding: 12px; margin-bottom: 16px;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 16px;">🔗</span>
<strong style="color: var(--warning);">Shared Template</strong>
</div>
<p style="color: var(--text-muted); font-size: 12px; margin: 0 0 8px;">
Changes will apply to all ${sharedItems.length} items using this modifier:
</p>
<p style="color: var(--text-secondary); font-size: 11px; margin: 0; max-height: 60px; overflow-y: auto;">
${sharedItems.map(n => this.escapeHtml(n)).join(', ')}
</p>
${rootItem ? `
<button class="btn btn-secondary" style="margin-top: 12px; font-size: 12px;"
onclick="MenuBuilder.detachFromTemplate('${rootItem.id}', '${option.id}')">
✂️ Detach for This Item Only
</button>
` : ''}
</div>
` : ''}
<div class="property-group">
<label>${levelName} Name</label>
<input type="text" value="${this.escapeHtml(option.name)}"
@ -1842,14 +1874,157 @@
},
// Update option at any depth
// IMPORTANT: Templates are shared across multiple items, so we need to update ALL copies
updateOption(parentId, optionId, field, value) {
console.log('[MenuBuilder] updateOption called:', { parentId, optionId, field, value });
this.saveState();
// Get the dbId of the option being updated (to find all copies)
const result = this.findOptionWithParent(parentId, optionId);
if (result && result.option) {
result.option[field] = value;
if (!result || !result.option) {
console.error('[MenuBuilder] Could not find option to update!', { parentId, optionId });
return;
}
const optionDbId = result.option.dbId;
const parentDbId = result.parent.dbId;
const isTopLevelModifier = result.parentType === 'item'; // parent is the menu item itself
console.log('[MenuBuilder] Updating option dbId:', optionDbId, 'in parent dbId:', parentDbId, 'isTopLevel:', isTopLevelModifier);
// Update ALL instances of this option across all items (templates are duplicated)
let updateCount = 0;
for (const cat of this.menu.categories) {
for (const item of cat.items) {
for (const mod of (item.modifiers || [])) {
// If editing a top-level modifier group, match by the modifier's dbId directly
if (isTopLevelModifier && mod.dbId === optionDbId) {
mod[field] = value;
updateCount++;
console.log('[MenuBuilder] Updated top-level modifier in item:', item.name);
}
// If editing a nested option, find within the modifier's options
else if (!isTopLevelModifier && mod.dbId === parentDbId) {
// Find the option within this modifier
const opt = (mod.options || []).find(o => 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');
}
},