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/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/addresses/states.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/debug/", 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/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/getCart.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/orders/setLineItem.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/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/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/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 // Setup/Import endpoints
if (findNoCase("/api/setup/importBusiness.cfm", request._api_path)) request._api_isPublic = true; 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); if (len(requestBody)) data = deserializeJSON(requestBody);
} catch (any e) {} } 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; businessId = structKeyExists(data, "BusinessID") ? val(data.BusinessID) : 17;
q = queryExecute(" 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) // 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 // Filter by business and use DISTINCT to avoid duplicates from multiple template links
qTemplateChildren = queryExecute(" qTemplateChildren = queryExecute("
SELECT DISTINCT SELECT DISTINCT
@ -225,7 +226,9 @@ try {
c.ItemName, c.ItemName,
c.ItemPrice, c.ItemPrice,
c.ItemIsCheckedByDefault as IsDefault, c.ItemIsCheckedByDefault as IsDefault,
c.ItemSortOrder c.ItemSortOrder,
c.ItemRequiresChildSelection as RequiresSelection,
c.ItemMaxNumSelectionReq as MaxSelections
FROM Items c FROM Items c
WHERE c.ItemParentItemID IN ( WHERE c.ItemParentItemID IN (
SELECT DISTINCT t.ItemID SELECT DISTINCT t.ItemID
@ -238,10 +241,47 @@ try {
ORDER BY c.ItemSortOrder, c.ItemName ORDER BY c.ItemSortOrder, c.ItemName
", { businessID: businessID }, { datasource: "payfrit" }); ", { 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 = {}; childrenByTemplate = {};
for (child in qTemplateChildren) { for (child in qTemplateChildren) {
parentID = child.ParentItemID; 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)) { if (!structKeyExists(childrenByTemplate, parentID)) {
childrenByTemplate[parentID] = []; childrenByTemplate[parentID] = [];
} }
@ -252,10 +292,17 @@ try {
"price": child.ItemPrice, "price": child.ItemPrice,
"isDefault": child.IsDefault == 1 ? true : false, "isDefault": child.IsDefault == 1 ? true : false,
"sortOrder": child.ItemSortOrder, "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 // Build template lookup with their children
templatesById = {}; templatesById = {};
for (tmpl in qTemplates) { for (tmpl in qTemplates) {

View file

@ -7,8 +7,86 @@
response = { "OK": false }; 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 { try {
requestBody = toString(getHttpRequestData().content); requestBody = toString(getHttpRequestData().content);
fileAppend(logFile, "[#dateTimeFormat(now(), 'yyyy-mm-dd HH:nn:ss')#] Request received, length: #len(requestBody)##chr(10)#");
if (!len(requestBody)) { if (!len(requestBody)) {
throw("Request body is required"); throw("Request body is required");
} }
@ -17,6 +95,8 @@ try {
businessID = val(jsonData.BusinessID ?: 0); businessID = val(jsonData.BusinessID ?: 0);
menu = jsonData.Menu ?: {}; 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) { if (businessID == 0) {
throw("BusinessID is required"); throw("BusinessID is required");
} }
@ -25,6 +105,16 @@ try {
throw("Menu categories are required"); 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) // Check if new schema is active (ItemBusinessID column exists and has data)
newSchemaActive = false; newSchemaActive = false;
try { try {
@ -239,6 +329,12 @@ try {
requiresSelection: requiresSelection, requiresSelection: requiresSelection,
maxSelections: maxSelections 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) { } else if (modDbId > 0) {
// Update existing direct modifier // Update existing direct modifier
queryExecute(" queryExecute("
@ -259,6 +355,12 @@ try {
requiresSelection: requiresSelection, requiresSelection: requiresSelection,
maxSelections: maxSelections 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 { } else {
// Insert new direct modifier (non-template) // Insert new direct modifier (non-template)
queryExecute(" queryExecute("
@ -281,6 +383,14 @@ try {
requiresSelection: requiresSelection, requiresSelection: requiresSelection,
maxSelections: maxSelections 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++; modSortOrder++;
} }

View file

@ -1,13 +1,12 @@
<cfsetting showdebugoutput="false"> <cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true"> <cfsetting enablecfoutputonly="true">
<!--- Force recompile: 2026-01-09 --->
<cfcontent type="application/json; charset=utf-8" reset="true"> <cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store"> <cfheader name="Cache-Control" value="no-store">
<cfscript> <cfscript>
/** /**
* Get Order Detail * 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 * GET: ?OrderID=123
* POST: { OrderID: 123 } * POST: { OrderID: 123 }
@ -55,13 +54,15 @@ try {
o.OrderAddedOn, o.OrderAddedOn,
o.OrderLastEditedOn, o.OrderLastEditedOn,
o.OrderSubmittedOn, o.OrderSubmittedOn,
o.OrderTipAmount,
u.UserFirstName, u.UserFirstName,
u.UserLastName, u.UserLastName,
u.UserContactNumber, u.UserContactNumber,
u.UserEmailAddress, u.UserEmailAddress,
sp.ServicePointName, sp.ServicePointName,
sp.ServicePointTypeID, sp.ServicePointTypeID,
b.BusinessName b.BusinessName,
b.BusinessTaxRate
FROM Orders o FROM Orders o
LEFT JOIN Users u ON u.UserID = o.OrderUserID LEFT JOIN Users u ON u.UserID = o.OrderUserID
LEFT JOIN ServicePoints sp ON sp.ServicePointID = o.OrderServicePointID LEFT JOIN ServicePoints sp ON sp.ServicePointID = o.OrderServicePointID
@ -140,29 +141,35 @@ try {
subtotal += itemTotal; subtotal += itemTotal;
} }
// Calculate tax (assume 8.75% if not stored) // Calculate tax using business tax rate or default 8.25%
taxRate = 0.0875; taxRate = isNumeric(qOrder.BusinessTaxRate) && qOrder.BusinessTaxRate > 0 ? qOrder.BusinessTaxRate : 0.0825;
tax = subtotal * taxRate; tax = subtotal * taxRate;
// Look up tip from Payments table if exists // Get tip from order
tip = 0; tip = isNumeric(qOrder.OrderTipAmount) ? qOrder.OrderTipAmount : 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
}
// Calculate total // Calculate total
total = subtotal + tax + tip; 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 // Build response
order = { order = {
"OrderID": qOrder.OrderID, "OrderID": qOrder.OrderID,
@ -192,7 +199,8 @@ try {
"Name": qOrder.ServicePointName, "Name": qOrder.ServicePointName,
"TypeID": qOrder.ServicePointTypeID "TypeID": qOrder.ServicePointTypeID
}, },
"LineItems": lineItems "LineItems": lineItems,
"Staff": staff
}; };
response["OK"] = true; response["OK"] = true;
@ -212,8 +220,10 @@ function getStatusText(status) {
case 1: return "Submitted"; case 1: return "Submitted";
case 2: return "In Progress"; case 2: return "In Progress";
case 3: return "Ready"; case 3: return "Ready";
case 4: return "Completed"; case 4: return "On the Way";
case 5: return "Cancelled"; case 5: return "Complete";
case 6: return "Cancelled";
case 7: return "Deleted";
default: return "Unknown"; default: return "Unknown";
} }
} }

View file

@ -125,9 +125,11 @@
i.ItemName, i.ItemName,
i.ItemParentItemID, i.ItemParentItemID,
i.ItemIsCheckedByDefault, i.ItemIsCheckedByDefault,
i.ItemStationID i.ItemStationID,
parent.ItemName AS ItemParentName
FROM OrderLineItems oli FROM OrderLineItems oli
INNER JOIN Items i ON i.ItemID = oli.OrderLineItemItemID INNER JOIN Items i ON i.ItemID = oli.OrderLineItemItemID
LEFT JOIN Items parent ON parent.ItemID = i.ItemParentItemID
WHERE oli.OrderLineItemOrderID = ? WHERE oli.OrderLineItemOrderID = ?
AND oli.OrderLineItemIsDeleted = b'0' AND oli.OrderLineItemIsDeleted = b'0'
AND (i.ItemStationID = ? OR i.ItemStationID = 0 OR i.ItemStationID IS NULL OR oli.OrderLineItemParentOrderLineItemID > 0) AND (i.ItemStationID = ? OR i.ItemStationID = 0 OR i.ItemStationID IS NULL OR oli.OrderLineItemParentOrderLineItemID > 0)
@ -149,9 +151,11 @@
i.ItemName, i.ItemName,
i.ItemParentItemID, i.ItemParentItemID,
i.ItemIsCheckedByDefault, i.ItemIsCheckedByDefault,
i.ItemStationID i.ItemStationID,
parent.ItemName AS ItemParentName
FROM OrderLineItems oli FROM OrderLineItems oli
INNER JOIN Items i ON i.ItemID = oli.OrderLineItemItemID INNER JOIN Items i ON i.ItemID = oli.OrderLineItemItemID
LEFT JOIN Items parent ON parent.ItemID = i.ItemParentItemID
WHERE oli.OrderLineItemOrderID = ? WHERE oli.OrderLineItemOrderID = ?
AND oli.OrderLineItemIsDeleted = b'0' AND oli.OrderLineItemIsDeleted = b'0'
ORDER BY oli.OrderLineItemID ORDER BY oli.OrderLineItemID
@ -169,6 +173,7 @@
"OrderLineItemRemark": qLineItems.OrderLineItemRemark, "OrderLineItemRemark": qLineItems.OrderLineItemRemark,
"ItemName": qLineItems.ItemName, "ItemName": qLineItems.ItemName,
"ItemParentItemID": qLineItems.ItemParentItemID, "ItemParentItemID": qLineItems.ItemParentItemID,
"ItemParentName": qLineItems.ItemParentName,
"ItemIsCheckedByDefault": qLineItems.ItemIsCheckedByDefault, "ItemIsCheckedByDefault": qLineItems.ItemIsCheckedByDefault,
"ItemStationID": qLineItems.ItemStationID "ItemStationID": qLineItems.ItemStationID
})> })>

View file

@ -57,7 +57,47 @@
{ datasource = "payfrit" } { 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"> <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 ---> <!--- If existing, undelete; else insert new --->
<cfset var qExisting = queryExecute( <cfset var qExisting = queryExecute(
" "
@ -71,7 +111,7 @@
[ [
{ value = arguments.OrderID, cfsqltype = "cf_sql_integer" }, { value = arguments.OrderID, cfsqltype = "cf_sql_integer" },
{ value = arguments.ParentLineItemID, 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" } { datasource = "payfrit" }
)> )>
@ -86,7 +126,7 @@
[ { value = qExisting.OrderLineItemID, cfsqltype = "cf_sql_integer" } ], [ { value = qExisting.OrderLineItemID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" } { datasource = "payfrit" }
)> )>
<cfset attachDefaultChildren(arguments.OrderID, qExisting.OrderLineItemID, qKids.ItemID)> <cfset attachDefaultChildren(arguments.OrderID, qExisting.OrderLineItemID, arguments.ItemID)>
<cfelse> <cfelse>
<cfset var NewLIID = nextId("OrderLineItems","OrderLineItemID")> <cfset var NewLIID = nextId("OrderLineItems","OrderLineItemID")>
<cfset queryExecute( <cfset queryExecute(
@ -119,15 +159,14 @@
{ value = NewLIID, cfsqltype = "cf_sql_integer" }, { value = NewLIID, cfsqltype = "cf_sql_integer" },
{ value = arguments.ParentLineItemID, cfsqltype = "cf_sql_integer" }, { value = arguments.ParentLineItemID, cfsqltype = "cf_sql_integer" },
{ value = arguments.OrderID, cfsqltype = "cf_sql_integer" }, { value = arguments.OrderID, cfsqltype = "cf_sql_integer" },
{ value = qKids.ItemID, cfsqltype = "cf_sql_integer" }, { value = arguments.ItemID, cfsqltype = "cf_sql_integer" },
{ value = qKids.ItemPrice, cfsqltype = "cf_sql_decimal" }, { value = arguments.ItemPrice, cfsqltype = "cf_sql_decimal" },
{ value = now(), cfsqltype = "cf_sql_timestamp" } { value = now(), cfsqltype = "cf_sql_timestamp" }
], ],
{ datasource = "payfrit" } { datasource = "payfrit" }
)> )>
<cfset attachDefaultChildren(arguments.OrderID, NewLIID, qKids.ItemID)> <cfset attachDefaultChildren(arguments.OrderID, NewLIID, arguments.ItemID)>
</cfif> </cfif>
</cfloop>
</cffunction> </cffunction>
<cffunction name="loadCartPayload" access="public" returntype="struct" output="false"> <cffunction name="loadCartPayload" access="public" returntype="struct" output="false">
@ -137,23 +176,25 @@
<cfset var qOrder = queryExecute( <cfset var qOrder = queryExecute(
" "
SELECT SELECT
OrderID, o.OrderID,
OrderUUID, o.OrderUUID,
OrderUserID, o.OrderUserID,
OrderBusinessID, o.OrderBusinessID,
OrderBusinessDeliveryMultiplier, o.OrderBusinessDeliveryMultiplier,
OrderTypeID, o.OrderTypeID,
OrderDeliveryFee, o.OrderDeliveryFee,
OrderStatusID, o.OrderStatusID,
OrderAddressID, o.OrderAddressID,
OrderPaymentID, o.OrderPaymentID,
OrderRemarks, o.OrderRemarks,
OrderAddedOn, o.OrderAddedOn,
OrderLastEditedOn, o.OrderLastEditedOn,
OrderSubmittedOn, o.OrderSubmittedOn,
OrderServicePointID o.OrderServicePointID,
FROM Orders COALESCE(b.BusinessDeliveryFlatFee, 0) AS BusinessDeliveryFee
WHERE OrderID = ? FROM Orders o
LEFT JOIN Businesses b ON b.BusinessID = o.OrderBusinessID
WHERE o.OrderID = ?
LIMIT 1 LIMIT 1
", ",
[ { value = arguments.OrderID, cfsqltype = "cf_sql_integer" } ], [ { value = arguments.OrderID, cfsqltype = "cf_sql_integer" } ],
@ -179,7 +220,8 @@
"OrderAddedOn": qOrder.OrderAddedOn, "OrderAddedOn": qOrder.OrderAddedOn,
"OrderLastEditedOn": qOrder.OrderLastEditedOn, "OrderLastEditedOn": qOrder.OrderLastEditedOn,
"OrderSubmittedOn": qOrder.OrderSubmittedOn, "OrderSubmittedOn": qOrder.OrderSubmittedOn,
"OrderServicePointID": qOrder.OrderServicePointID "OrderServicePointID": qOrder.OrderServicePointID,
"BusinessDeliveryFee": qOrder.BusinessDeliveryFee
}> }>
<cfset var qLI = queryExecute( <cfset var qLI = queryExecute(
@ -227,6 +269,13 @@
<cfset data = readJsonBody()> <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 OrderID = val( structKeyExists(data,"OrderID") ? data.OrderID : 0 )>
<cfset ParentLineItemID = val( structKeyExists(data,"ParentOrderLineItemID") ? data.ParentOrderLineItemID : 0 )> <cfset ParentLineItemID = val( structKeyExists(data,"ParentOrderLineItemID") ? data.ParentOrderLineItemID : 0 )>
<cfset ItemID = val( structKeyExists(data,"ItemID") ? data.ItemID : 0 )> <cfset ItemID = val( structKeyExists(data,"ItemID") ? data.ItemID : 0 )>
@ -331,8 +380,12 @@
{ datasource = "payfrit" } { 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> <cfif qExisting.recordCount GT 0>
<!--- Update existing ---> <!--- Update existing --->
<cfset arrayAppend(request.attachDebug, "Path: update existing")>
<cfif IsSelected> <cfif IsSelected>
<cfset queryExecute( <cfset queryExecute(
" "
@ -355,7 +408,12 @@
)> )>
<!--- Attach default children for this node (recursively) ---> <!--- 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 attachDefaultChildren(OrderID, qExisting.OrderLineItemID, ItemID)>
<cfset arrayAppend(request.attachDebug, "AFTER attachDefaultChildren call")>
<cfelse> <cfelse>
<cfset queryExecute( <cfset queryExecute(
" "
@ -416,6 +474,7 @@
</cfif> </cfif>
<!--- Touch order last edited ---> <!--- Touch order last edited --->
<cftry>
<cfset queryExecute( <cfset queryExecute(
"UPDATE Orders SET OrderLastEditedOn = ? WHERE OrderID = ?", "UPDATE Orders SET OrderLastEditedOn = ? WHERE OrderID = ?",
[ [
@ -424,16 +483,41 @@
], ],
{ datasource = "payfrit" } { datasource = "payfrit" }
)> )>
<cfset payload = loadCartPayload(OrderID)>
<cfset apiAbort(payload)>
<cfcatch> <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({ <cfset apiAbort({
"OK": false, "OK": false,
"ERROR": "server_error", "ERROR": "server_error",
"MESSAGE": "DB error setting line item", "MESSAGE": "DB error setting line item: " & cfcatch.message & " | " & (structKeyExists(cfcatch, "detail") ? cfcatch.detail : "") & " | " & (structKeyExists(cfcatch, "sql") ? cfcatch.sql : ""),
"DETAIL": cfcatch.message "DETAIL": cfcatch.tagContext[1].template & ":" & cfcatch.tagContext[1].line
})> })>
</cfcatch> </cfcatch>
</cftry> </cftry>

View file

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

View file

@ -63,7 +63,29 @@
{ value = OrderID, cfsqltype = "cf_sql_integer" } { value = OrderID, cfsqltype = "cf_sql_integer" }
], { datasource = "payfrit" })> ], { 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> <cfset taskCreated = false>
<cfif NewStatusID EQ 3 AND oldStatusID NEQ 3> <cfif NewStatusID EQ 3 AND oldStatusID NEQ 3>
<cftry> <cftry>
@ -73,23 +95,60 @@
", [ { value = OrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })> ", [ { value = OrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
<cfif qExisting.recordCount EQ 0> <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(" <cfset queryExecute("
INSERT INTO Tasks ( INSERT INTO Tasks (
TaskBusinessID, TaskBusinessID,
TaskOrderID, TaskOrderID,
TaskTypeID, TaskTypeID,
TaskCategoryID,
TaskTitle,
TaskClaimedByUserID, TaskClaimedByUserID,
TaskAddedOn TaskAddedOn
) VALUES ( ) VALUES (
?, ?,
?, ?,
1, 1,
?,
?,
0, 0,
NOW() NOW()
) )
", [ ", [
{ value = qOrder.OrderBusinessID, cfsqltype = "cf_sql_integer" }, { 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" })> ], { datasource = "payfrit" })>
<cfset taskCreated = true> <cfset taskCreated = true>
</cfif> </cfif>

View file

@ -48,7 +48,8 @@ try {
orderID = val(eventData.metadata.order_id ?: 0); orderID = val(eventData.metadata.order_id ?: 0);
if (orderID > 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(" queryExecute("
UPDATE Orders UPDATE Orders
SET OrderPaymentStatus = 'paid', SET OrderPaymentStatus = 'paid',
@ -57,15 +58,6 @@ try {
WHERE OrderID = :orderID WHERE OrderID = :orderID
", { 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"); writeLog(file="stripe_webhooks", text="Order #orderID# marked as paid");
} }
break; break;

View file

@ -37,7 +37,7 @@
<cftry> <cftry>
<!--- Verify task exists and is unclaimed ---> <!--- Verify task exists and is unclaimed --->
<cfset qTask = queryExecute(" <cfset qTask = queryExecute("
SELECT TaskID, TaskClaimedByUserID, TaskBusinessID SELECT TaskID, TaskClaimedByUserID, TaskBusinessID, TaskOrderID
FROM Tasks FROM Tasks
WHERE TaskID = ? WHERE TaskID = ?
", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })> ", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
@ -62,6 +62,17 @@
{ value = TaskID, cfsqltype = "cf_sql_integer" } { value = TaskID, cfsqltype = "cf_sql_integer" }
], { datasource = "payfrit" })> ], { 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({ <cfset apiAbort({
"OK": true, "OK": true,
"ERROR": "", "ERROR": "",

View file

@ -70,12 +70,12 @@
WHERE TaskID = ? WHERE TaskID = ?
", [ { value = TaskID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })> ", [ { 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> <cfset orderUpdated = false>
<cfif qTask.TaskOrderID GT 0> <cfif qTask.TaskOrderID GT 0>
<cfset queryExecute(" <cfset queryExecute("
UPDATE Orders UPDATE Orders
SET OrderStatusID = 4, SET OrderStatusID = 5,
OrderLastEditedOn = NOW() OrderLastEditedOn = NOW()
WHERE OrderID = ? WHERE OrderID = ?
", [ { value = qTask.TaskOrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })> ", [ { value = qTask.TaskOrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>

View file

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

View file

@ -247,6 +247,7 @@ function updateStatus(isConnected, message) {
// Render orders to DOM // Render orders to DOM
function renderOrders() { function renderOrders() {
const grid = document.getElementById('ordersGrid'); const grid = document.getElementById('ordersGrid');
console.log('renderOrders called, orders count:', orders.length);
if (orders.length === 0) { if (orders.length === 0) {
grid.innerHTML = ` grid.innerHTML = `
<div class="empty-state"> <div class="empty-state">
@ -259,6 +260,14 @@ function renderOrders() {
`; `;
return; 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(''); grid.innerHTML = orders.map(order => renderOrder(order)).join('');
} }
@ -301,6 +310,7 @@ function renderOrder(order) {
// Render line item with modifiers // Render line item with modifiers
function renderLineItem(item, allItems) { function renderLineItem(item, allItems) {
const modifiers = allItems.filter(mod => mod.OrderLineItemParentOrderLineItemID === item.OrderLineItemID); 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 ` return `
<div class="line-item"> <div class="line-item">
<div class="line-item-main"> <div class="line-item-main">
@ -330,24 +340,34 @@ function renderAllModifiers(modifiers, allItems) {
} }
const leafModifiers = []; 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 => { mods.forEach(mod => {
// Show ALL selected modifiers - don't skip defaults // Show ALL selected modifiers - don't skip defaults
// The customer made a choice and it should be visible on the KDS // The customer made a choice and it should be visible on the KDS
const children = allItems.filter(item => item.OrderLineItemParentOrderLineItemID === mod.OrderLineItemID); 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) { if (children.length === 0) {
// This is a leaf node (actual selection) // 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 { } else {
// Has children, recurse deeper // Has children, recurse deeper
collectLeafModifiers(children); collectLeafModifiers(children, depth + 1);
} }
}); });
} }
collectLeafModifiers(modifiers); collectLeafModifiers(modifiers);
leafModifiers.forEach(({ path }) => { console.log(` Total leaf modifiers found: ${leafModifiers.length}`);
html += `<div class="modifier">+ ${escapeHtml(path.join(': '))}</div>`; 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>'; html += '</div>';

View file

@ -1722,7 +1722,8 @@
for (const item of cat.items) { for (const item of cat.items) {
if (item.id === parentId) { if (item.id === parentId) {
const opt = item.modifiers.find(m => m.id === optionId); 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 // Check nested modifiers
const result = this.findInModifiers(item.modifiers, parentId, optionId); const result = this.findInModifiers(item.modifiers, parentId, optionId);
@ -1758,18 +1759,49 @@
const result = this.findOptionWithParent(parentId, optionId); const result = this.findOptionWithParent(parentId, optionId);
if (result) { if (result) {
this.selectedData = { type: 'option', data: result.option, parent: result.parent, depth: depth }; // Store rootItem for detach functionality
this.showPropertiesForOption(result.option, result.parent, depth); 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 // 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 hasOptions = option.options && option.options.length > 0;
const levelName = depth === 1 ? 'Modifier Group' : (depth === 2 ? 'Option' : `Level ${depth} Option`); const levelName = depth === 1 ? 'Modifier Group' : (depth === 2 ? 'Option' : `Level ${depth} Option`);
const isModifierGroup = depth === 1 && hasOptions; 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 = ` 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"> <div class="property-group">
<label>${levelName} Name</label> <label>${levelName} Name</label>
<input type="text" value="${this.escapeHtml(option.name)}" <input type="text" value="${this.escapeHtml(option.name)}"
@ -1842,14 +1874,157 @@
}, },
// Update option at any depth // Update option at any depth
// IMPORTANT: Templates are shared across multiple items, so we need to update ALL copies
updateOption(parentId, optionId, field, value) { updateOption(parentId, optionId, field, value) {
console.log('[MenuBuilder] updateOption called:', { parentId, optionId, field, value });
this.saveState(); this.saveState();
// Get the dbId of the option being updated (to find all copies)
const result = this.findOptionWithParent(parentId, optionId); const result = this.findOptionWithParent(parentId, optionId);
if (result && result.option) { if (!result || !result.option) {
result.option[field] = value; 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.render();
this.selectOption(parentId, optionId, this.selectedData?.depth || 1); 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 // Delete option at any depth
@ -2489,23 +2664,59 @@
// Save menu to API // Save menu to API
async saveMenu() { async saveMenu() {
try { 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`, { const response = await fetch(`${this.config.apiBaseUrl}/menu/saveFromBuilder.cfm`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify(payload)
BusinessID: this.config.businessId,
Menu: this.menu
})
}); });
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) { if (data.OK) {
this.toast('Menu saved successfully!', 'success'); this.toast('Menu saved successfully!', 'success');
// Reload menu to get updated dbIds
await this.loadMenu();
} else { } else {
console.error('[MenuBuilder] Save failed:', data.ERROR, data.DETAIL);
this.toast(data.ERROR || 'Failed to save', 'error'); this.toast(data.ERROR || 'Failed to save', 'error');
} }
} catch (err) { } catch (err) {
console.error('[MenuBuilder] Save error:', err); console.error('[MenuBuilder] Save error:', err);
this.toast('Error saving menu', 'error'); this.toast('Error saving menu: ' + err.message, 'error');
} }
}, },