Fix CFML iteration bugs and improve wizard functionality

- Fix for...in loops on query results in getForBuilder.cfm (only iterated once)
- Remove illegal var keyword from loop variables in saveWizard.cfm
- Only create food running tasks for dine-in orders (skip takeaway/delivery)
- Add image preview modal for modifier source images in wizard
- Add data clearing utilities (clearAllData, clearOrders)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-16 19:40:37 -08:00
parent 3384f128e1
commit d73c4d60d3
7 changed files with 498 additions and 346 deletions

View file

@ -95,6 +95,8 @@ if (len(request._api_path)) {
if (findNoCase("/api/beacons/list_all.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/beacons/list_all.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/beacons/getBusinessFromBeacon.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/beacons/getBusinessFromBeacon.cfm", request._api_path)) request._api_isPublic = true;
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/menu/clearAllData.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/menu/clearOrders.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/addresses/debug.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/addresses/debug.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;

50
api/menu/clearAllData.cfm Normal file
View file

@ -0,0 +1,50 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfset request.skipAuth = true>
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
response = { "OK": false };
try {
requestBody = toString(getHttpRequestData().content);
requestData = {};
if (len(requestBody)) {
requestData = deserializeJSON(requestBody);
}
confirmDelete = requestData.confirm ?: "";
if (confirmDelete != "NUKE_EVERYTHING") {
throw("Must pass confirm: 'NUKE_EVERYTHING' to proceed");
}
// Get counts before deletion
qItemCount = queryExecute("SELECT COUNT(*) as cnt FROM Items", {}, { datasource: "payfrit" });
qCatCount = queryExecute("SELECT COUNT(*) as cnt FROM Categories", {}, { datasource: "payfrit" });
qLinkCount = queryExecute("SELECT COUNT(*) as cnt FROM ItemTemplateLinks", {}, { datasource: "payfrit" });
// Delete in correct order (foreign key constraints)
queryExecute("DELETE FROM ItemTemplateLinks", {}, { datasource: "payfrit" });
queryExecute("DELETE FROM Items", {}, { datasource: "payfrit" });
queryExecute("DELETE FROM Categories", {}, { datasource: "payfrit" });
response = {
"OK": true,
"deleted": {
"items": qItemCount.cnt,
"categories": qCatCount.cnt,
"templateLinks": qLinkCount.cnt
}
};
} catch (any e) {
response = {
"OK": false,
"ERROR": e.message,
"DETAIL": e.detail ?: ""
};
}
writeOutput(serializeJSON(response));
</cfscript>

53
api/menu/clearOrders.cfm Normal file
View file

@ -0,0 +1,53 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfset request.skipAuth = true>
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
response = { "OK": false };
try {
requestBody = toString(getHttpRequestData().content);
requestData = {};
if (len(requestBody)) {
requestData = deserializeJSON(requestBody);
}
confirmDelete = requestData.confirm ?: "";
if (confirmDelete != "NUKE_ORDERS") {
throw("Must pass confirm: 'NUKE_ORDERS' to proceed");
}
// Get counts before deletion
qOrderLineItems = queryExecute("SELECT COUNT(*) as cnt FROM OrderLineItems", {}, { datasource: "payfrit" });
qOrders = queryExecute("SELECT COUNT(*) as cnt FROM Orders", {}, { datasource: "payfrit" });
qAddresses = queryExecute("SELECT COUNT(*) as cnt FROM Addresses", {}, { datasource: "payfrit" });
qTasks = queryExecute("SELECT COUNT(*) as cnt FROM Tasks", {}, { datasource: "payfrit" });
// Delete in correct order (foreign key constraints)
queryExecute("DELETE FROM Tasks", {}, { datasource: "payfrit" });
queryExecute("DELETE FROM OrderLineItems", {}, { datasource: "payfrit" });
queryExecute("DELETE FROM Orders", {}, { datasource: "payfrit" });
queryExecute("DELETE FROM Addresses", {}, { datasource: "payfrit" });
response = {
"OK": true,
"deleted": {
"tasks": qTasks.cnt,
"lineItems": qOrderLineItems.cnt,
"orders": qOrders.cnt,
"addresses": qAddresses.cnt
}
};
} catch (any e) {
response = {
"OK": false,
"ERROR": e.message,
"DETAIL": e.detail ?: ""
};
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -7,20 +7,38 @@
/** /**
* Get Menu for Builder * Get Menu for Builder
* Returns categories and items in structured format for the menu builder UI * Returns categories and items in structured format for the menu builder UI
*
* POST: { BusinessID: int }
*
* Unified schema:
* - Categories = Items at ParentID=0 that have menu items as children
* - Templates = Items at ParentID=0 that appear in ItemTemplateLinks
* - Menu items have ItemParentItemID pointing to their category
* - All items have ItemBusinessID for filtering
*/ */
response = { "OK": false }; response = { "OK": false };
// Recursive function to build nested options
function buildOptionsTree(allOptions, parentId) {
var result = [];
for (var i = 1; i <= allOptions.recordCount; i++) {
if (allOptions.ParentItemID[i] == parentId) {
var children = buildOptionsTree(allOptions, allOptions.ItemID[i]);
arrayAppend(result, {
"id": "opt_" & allOptions.ItemID[i],
"dbId": allOptions.ItemID[i],
"name": allOptions.ItemName[i],
"price": allOptions.ItemPrice[i],
"isDefault": allOptions.IsDefault[i] == 1 ? true : false,
"sortOrder": allOptions.ItemSortOrder[i],
"requiresSelection": isNull(allOptions.RequiresSelection[i]) ? false : (allOptions.RequiresSelection[i] == 1),
"maxSelections": isNull(allOptions.MaxSelections[i]) ? 0 : allOptions.MaxSelections[i],
"options": children
});
}
}
if (arrayLen(result) > 1) {
arraySort(result, function(a, b) {
return a.sortOrder - b.sortOrder;
});
}
return result;
}
try { try {
// Get request body
requestBody = toString(getHttpRequestData().content); requestBody = toString(getHttpRequestData().content);
requestData = {}; requestData = {};
if (len(requestBody)) { if (len(requestBody)) {
@ -39,113 +57,30 @@ try {
abort; abort;
} }
// Check if new schema is active (ItemBusinessID column exists and has data) // Check if Categories table has data for this business
newSchemaActive = false; hasCategoriesData = false;
try { try {
qCheck = queryExecute(" qCatCheck = queryExecute("
SELECT COUNT(*) as cnt FROM Items SELECT 1 FROM Categories WHERE CategoryBusinessID = :businessID LIMIT 1
WHERE ItemBusinessID = :businessID AND ItemBusinessID > 0
", { businessID: businessID }, { datasource: "payfrit" }); ", { businessID: businessID }, { datasource: "payfrit" });
newSchemaActive = (qCheck.cnt > 0); hasCategoriesData = (qCatCheck.recordCount > 0);
} catch (any e) { } catch (any e) {
newSchemaActive = false; hasCategoriesData = false;
} }
if (newSchemaActive) { if (hasCategoriesData) {
// NEW SCHEMA: Check if Categories table has data for this business // OLD SCHEMA: Use Categories table for categories
hasCategoriesData = false;
try {
qCatCheck = queryExecute("
SELECT COUNT(*) as cnt FROM Categories WHERE CategoryBusinessID = :businessID
", { businessID: businessID }, { datasource: "payfrit" });
hasCategoriesData = (qCatCheck.cnt > 0);
} catch (any e) {
hasCategoriesData = false;
}
if (hasCategoriesData) {
// Use Categories table
qCategories = queryExecute("
SELECT
CategoryID,
CategoryName,
CategorySortOrder as ItemSortOrder
FROM Categories
WHERE CategoryBusinessID = :businessID
ORDER BY CategorySortOrder, CategoryName
", { businessID: businessID }, { datasource: "payfrit" });
// Get menu items with CategoryID
qItems = queryExecute("
SELECT
i.ItemID,
i.ItemCategoryID as CategoryItemID,
i.ItemName,
i.ItemDescription,
i.ItemPrice,
i.ItemSortOrder,
i.ItemIsActive
FROM Items i
WHERE i.ItemBusinessID = :businessID
AND i.ItemIsActive = 1
AND i.ItemCategoryID > 0
AND NOT EXISTS (
SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID
)
ORDER BY i.ItemSortOrder, i.ItemName
", { businessID: businessID }, { datasource: "payfrit" });
} else {
// Fallback: Categories are Items at ParentID=0 with children (not in ItemTemplateLinks)
qCategories = queryExecute("
SELECT DISTINCT
p.ItemID as CategoryID,
p.ItemName as CategoryName,
p.ItemSortOrder
FROM Items p
INNER JOIN Items c ON c.ItemParentItemID = p.ItemID
WHERE p.ItemBusinessID = :businessID
AND p.ItemParentItemID = 0
AND p.ItemIsActive = 1
AND NOT EXISTS (
SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = p.ItemID
)
ORDER BY p.ItemSortOrder, p.ItemName
", { businessID: businessID }, { datasource: "payfrit" });
// Get all menu items (children of category Items, not templates)
qItems = queryExecute("
SELECT
i.ItemID,
i.ItemParentItemID as CategoryItemID,
i.ItemName,
i.ItemDescription,
i.ItemPrice,
i.ItemSortOrder,
i.ItemIsActive
FROM Items i
INNER JOIN Items cat ON cat.ItemID = i.ItemParentItemID
WHERE i.ItemBusinessID = :businessID
AND i.ItemIsActive = 1
AND cat.ItemParentItemID = 0
AND NOT EXISTS (
SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = cat.ItemID
)
ORDER BY i.ItemSortOrder, i.ItemName
", { businessID: businessID }, { datasource: "payfrit" });
}
} else {
// OLD SCHEMA: Use Categories table
qCategories = queryExecute(" qCategories = queryExecute("
SELECT SELECT
CategoryID, CategoryID,
CategoryName, CategoryName,
0 as ItemSortOrder CategorySortOrder as ItemSortOrder
FROM Categories FROM Categories
WHERE CategoryBusinessID = :businessID WHERE CategoryBusinessID = :businessID
ORDER BY CategoryName ORDER BY CategorySortOrder, CategoryName
", { businessID: businessID }, { datasource: "payfrit" }); ", { businessID: businessID }, { datasource: "payfrit" });
// Get menu items - items that belong to categories (not modifiers)
qItems = queryExecute(" qItems = queryExecute("
SELECT SELECT
i.ItemID, i.ItemID,
@ -156,72 +91,135 @@ try {
i.ItemSortOrder, i.ItemSortOrder,
i.ItemIsActive i.ItemIsActive
FROM Items i FROM Items i
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID WHERE i.ItemBusinessID = :businessID
WHERE c.CategoryBusinessID = :businessID
AND i.ItemIsActive = 1 AND i.ItemIsActive = 1
AND i.ItemParentItemID = 0 AND i.ItemCategoryID > 0
ORDER BY i.ItemSortOrder, i.ItemName ORDER BY i.ItemSortOrder, i.ItemName
", { businessID: businessID }, { datasource: "payfrit" }); ", { businessID: businessID }, { datasource: "payfrit" });
}
// Get template links (which templates are linked to which menu items) // Get direct modifiers (items with ParentItemID pointing to menu items, not categories)
qTemplateLinks = queryExecute(" qDirectModifiers = queryExecute("
SELECT SELECT
tl.ItemID as ParentItemID, m.ItemID,
tl.TemplateItemID, m.ItemParentItemID as ParentItemID,
tl.SortOrder, m.ItemName,
t.ItemName as TemplateName, m.ItemPrice,
t.ItemPrice as TemplatePrice, m.ItemIsCheckedByDefault as IsDefault,
t.ItemIsCheckedByDefault as TemplateIsDefault m.ItemSortOrder,
FROM ItemTemplateLinks tl m.ItemRequiresChildSelection as RequiresSelection,
INNER JOIN Items t ON t.ItemID = tl.TemplateItemID m.ItemMaxNumSelectionReq as MaxSelections
ORDER BY tl.ItemID, tl.SortOrder FROM Items m
", {}, { datasource: "payfrit" }); WHERE m.ItemBusinessID = :businessID
AND m.ItemIsActive = 1
// Get all templates for this business AND m.ItemParentItemID > 0
// Templates are Items with ItemCategoryID=0 and ItemParentItemID=0 AND (m.ItemCategoryID = 0 OR m.ItemCategoryID IS NULL)
if (newSchemaActive) { ORDER BY m.ItemSortOrder, m.ItemName
qTemplates = queryExecute("
SELECT DISTINCT
t.ItemID,
t.ItemName,
t.ItemPrice,
t.ItemIsCheckedByDefault as IsDefault,
t.ItemSortOrder,
t.ItemRequiresChildSelection as RequiresSelection,
t.ItemMaxNumSelectionReq as MaxSelections
FROM Items t
WHERE t.ItemBusinessID = :businessID
AND t.ItemCategoryID = 0
AND t.ItemParentItemID = 0
AND t.ItemIsActive = 1
ORDER BY t.ItemSortOrder, t.ItemName
", { businessID: businessID }, { datasource: "payfrit" }); ", { businessID: businessID }, { datasource: "payfrit" });
} else { } else {
qTemplates = queryExecute(" // NEW UNIFIED SCHEMA: Categories are Items at ParentID=0 with children
qCategories = queryExecute("
SELECT DISTINCT SELECT DISTINCT
t.ItemID, p.ItemID as CategoryID,
t.ItemName, p.ItemName as CategoryName,
t.ItemPrice, p.ItemSortOrder
t.ItemIsCheckedByDefault as IsDefault, FROM Items p
t.ItemSortOrder, INNER JOIN Items c ON c.ItemParentItemID = p.ItemID
t.ItemRequiresChildSelection as RequiresSelection, WHERE p.ItemBusinessID = :businessID
t.ItemMaxNumSelectionReq as MaxSelections AND p.ItemParentItemID = 0
FROM Items t AND p.ItemIsActive = 1
INNER JOIN ItemTemplateLinks tl ON tl.TemplateItemID = t.ItemID AND NOT EXISTS (
INNER JOIN Items i ON i.ItemID = tl.ItemID SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = p.ItemID
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID )
WHERE c.CategoryBusinessID = :businessID ORDER BY p.ItemSortOrder, p.ItemName
AND t.ItemIsActive = 1 ", { businessID: businessID }, { datasource: "payfrit" });
ORDER BY t.ItemSortOrder, t.ItemName
qItems = queryExecute("
SELECT
i.ItemID,
i.ItemParentItemID as CategoryItemID,
i.ItemName,
i.ItemDescription,
i.ItemPrice,
i.ItemSortOrder,
i.ItemIsActive
FROM Items i
INNER JOIN Items cat ON cat.ItemID = i.ItemParentItemID
WHERE i.ItemBusinessID = :businessID
AND i.ItemIsActive = 1
AND cat.ItemParentItemID = 0
AND NOT EXISTS (
SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = cat.ItemID
)
ORDER BY i.ItemSortOrder, i.ItemName
", { businessID: businessID }, { datasource: "payfrit" });
qDirectModifiers = queryExecute("
SELECT
m.ItemID,
m.ItemParentItemID as ParentItemID,
m.ItemName,
m.ItemPrice,
m.ItemIsCheckedByDefault as IsDefault,
m.ItemSortOrder,
m.ItemRequiresChildSelection as RequiresSelection,
m.ItemMaxNumSelectionReq as MaxSelections
FROM Items m
WHERE m.ItemBusinessID = :businessID
AND m.ItemIsActive = 1
AND m.ItemParentItemID > 0
ORDER BY m.ItemSortOrder, m.ItemName
", { businessID: businessID }, { datasource: "payfrit" }); ", { businessID: businessID }, { datasource: "payfrit" });
} }
// Get all children of templates (options within modifier groups) // Collect menu item IDs for filtering template links
// Get children of ALL templates for this business (not just linked ones) menuItemIds = [];
if (newSchemaActive) { for (i = 1; i <= qItems.recordCount; i++) {
arrayAppend(menuItemIds, qItems.ItemID[i]);
}
// Get template links ONLY for this business's menu items
qTemplateLinks = queryNew("ParentItemID,TemplateItemID,SortOrder");
if (arrayLen(menuItemIds) > 0) {
qTemplateLinks = queryExecute("
SELECT
tl.ItemID as ParentItemID,
tl.TemplateItemID,
tl.SortOrder
FROM ItemTemplateLinks tl
WHERE tl.ItemID IN (:itemIds)
ORDER BY tl.ItemID, tl.SortOrder
", { itemIds: { value: arrayToList(menuItemIds), cfsqltype: "cf_sql_varchar", list: true } }, { datasource: "payfrit" });
}
// Get templates for this business only
qTemplates = queryExecute("
SELECT DISTINCT
t.ItemID,
t.ItemName,
t.ItemPrice,
t.ItemIsCheckedByDefault as IsDefault,
t.ItemSortOrder,
t.ItemRequiresChildSelection as RequiresSelection,
t.ItemMaxNumSelectionReq as MaxSelections
FROM Items t
WHERE t.ItemBusinessID = :businessID
AND (t.ItemCategoryID = 0 OR t.ItemCategoryID IS NULL)
AND t.ItemParentItemID = 0
AND t.ItemIsActive = 1
ORDER BY t.ItemSortOrder, t.ItemName
", { businessID: businessID }, { datasource: "payfrit" });
// Get template children (options within templates)
templateIds = [];
for (i = 1; i <= qTemplates.recordCount; i++) {
arrayAppend(templateIds, qTemplates.ItemID[i]);
}
qTemplateChildren = queryNew("ItemID,ParentItemID,ItemName,ItemPrice,IsDefault,ItemSortOrder,RequiresSelection,MaxSelections");
if (arrayLen(templateIds) > 0) {
qTemplateChildren = queryExecute(" qTemplateChildren = queryExecute("
SELECT DISTINCT SELECT
c.ItemID, c.ItemID,
c.ItemParentItemID as ParentItemID, c.ItemParentItemID as ParentItemID,
c.ItemName, c.ItemName,
@ -231,173 +229,109 @@ try {
c.ItemRequiresChildSelection as RequiresSelection, c.ItemRequiresChildSelection as RequiresSelection,
c.ItemMaxNumSelectionReq as MaxSelections c.ItemMaxNumSelectionReq as MaxSelections
FROM Items c FROM Items c
WHERE c.ItemParentItemID IN ( WHERE c.ItemParentItemID IN (:templateIds)
SELECT t.ItemID AND c.ItemIsActive = 1
FROM Items t
WHERE t.ItemBusinessID = :businessID
AND t.ItemCategoryID = 0
AND t.ItemParentItemID = 0
)
AND c.ItemIsActive = 1
ORDER BY c.ItemSortOrder, c.ItemName ORDER BY c.ItemSortOrder, c.ItemName
", { businessID: businessID }, { datasource: "payfrit" }); ", { templateIds: { value: arrayToList(templateIds), cfsqltype: "cf_sql_varchar", list: true } }, { datasource: "payfrit" });
} else {
qTemplateChildren = queryExecute("
SELECT DISTINCT
c.ItemID,
c.ItemParentItemID as ParentItemID,
c.ItemName,
c.ItemPrice,
c.ItemIsCheckedByDefault as IsDefault,
c.ItemSortOrder,
c.ItemRequiresChildSelection as RequiresSelection,
c.ItemMaxNumSelectionReq as MaxSelections
FROM Items c
WHERE c.ItemParentItemID IN (
SELECT DISTINCT t.ItemID
FROM Items t
INNER JOIN ItemTemplateLinks tl ON tl.TemplateItemID = t.ItemID
INNER JOIN Items i ON i.ItemID = tl.ItemID
WHERE i.ItemBusinessID = :businessID
)
AND c.ItemIsActive = 1
ORDER BY c.ItemSortOrder, c.ItemName
", { businessID: businessID }, { datasource: "payfrit" });
} }
// Build lookup of children by parent ID (flat list for now) // Build templates lookup with their options
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] = [];
}
arrayAppend(childrenByTemplate[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": 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 = {}; templatesById = {};
for (tmpl in qTemplates) { for (i = 1; i <= qTemplates.recordCount; i++) {
templateID = tmpl.ItemID; templateID = qTemplates.ItemID[i];
children = structKeyExists(childrenByTemplate, templateID) ? childrenByTemplate[templateID] : []; options = buildOptionsTree(qTemplateChildren, templateID);
templatesById[templateID] = { templatesById[templateID] = {
"id": "mod_" & tmpl.ItemID, "id": "mod_" & qTemplates.ItemID[i],
"dbId": tmpl.ItemID, "dbId": qTemplates.ItemID[i],
"name": tmpl.ItemName, "name": qTemplates.ItemName[i],
"price": tmpl.ItemPrice, "price": qTemplates.ItemPrice[i],
"isDefault": tmpl.IsDefault == 1 ? true : false, "isDefault": qTemplates.IsDefault[i] == 1 ? true : false,
"sortOrder": tmpl.ItemSortOrder, "sortOrder": qTemplates.ItemSortOrder[i],
"isTemplate": true, "isTemplate": true,
"requiresSelection": isNull(tmpl.RequiresSelection) ? false : (tmpl.RequiresSelection == 1), "requiresSelection": isNull(qTemplates.RequiresSelection[i]) ? false : (qTemplates.RequiresSelection[i] == 1),
"maxSelections": isNull(tmpl.MaxSelections) ? 0 : tmpl.MaxSelections, "maxSelections": isNull(qTemplates.MaxSelections[i]) ? 0 : qTemplates.MaxSelections[i],
"options": children "options": options
}; };
} }
// Build modifier lookup by parent ItemID using template links // Build template links lookup by parent ItemID
modifiersByItem = {}; templateLinksByItem = {};
for (link in qTemplateLinks) { for (i = 1; i <= qTemplateLinks.recordCount; i++) {
parentID = link.ParentItemID; parentID = qTemplateLinks.ParentItemID[i];
templateID = link.TemplateItemID; templateID = qTemplateLinks.TemplateItemID[i];
if (!structKeyExists(modifiersByItem, parentID)) { if (!structKeyExists(templateLinksByItem, parentID)) {
modifiersByItem[parentID] = []; templateLinksByItem[parentID] = [];
} }
if (structKeyExists(templatesById, templateID)) { if (structKeyExists(templatesById, templateID)) {
tmpl = duplicate(templatesById[templateID]); tmpl = duplicate(templatesById[templateID]);
tmpl["sortOrder"] = link.SortOrder; tmpl["sortOrder"] = qTemplateLinks.SortOrder[i];
arrayAppend(modifiersByItem[parentID], tmpl); arrayAppend(templateLinksByItem[parentID], tmpl);
}
}
// Build nested direct modifiers for each menu item
directModsByItem = {};
for (itemId in menuItemIds) {
options = buildOptionsTree(qDirectModifiers, itemId);
if (arrayLen(options) > 0) {
directModsByItem[itemId] = options;
} }
} }
// Build items lookup by CategoryID // Build items lookup by CategoryID
itemsByCategory = {}; itemsByCategory = {};
for (item in qItems) { for (i = 1; i <= qItems.recordCount; i++) {
catID = item.CategoryItemID; catID = qItems.CategoryItemID[i];
if (!structKeyExists(itemsByCategory, catID)) { if (!structKeyExists(itemsByCategory, catID)) {
itemsByCategory[catID] = []; itemsByCategory[catID] = [];
} }
itemID = item.ItemID; itemID = qItems.ItemID[i];
itemModifiers = structKeyExists(modifiersByItem, itemID) ? modifiersByItem[itemID] : [];
// Get template-linked modifiers
itemModifiers = structKeyExists(templateLinksByItem, itemID) ? duplicate(templateLinksByItem[itemID]) : [];
// Add direct modifiers
if (structKeyExists(directModsByItem, itemID)) {
directMods = directModsByItem[itemID];
for (j = 1; j <= arrayLen(directMods); j++) {
arrayAppend(itemModifiers, directMods[j]);
}
}
// Sort modifiers by sortOrder
if (arrayLen(itemModifiers) > 1) {
arraySort(itemModifiers, function(a, b) {
return a.sortOrder - b.sortOrder;
});
}
arrayAppend(itemsByCategory[catID], { arrayAppend(itemsByCategory[catID], {
"id": "item_" & item.ItemID, "id": "item_" & qItems.ItemID[i],
"dbId": item.ItemID, "dbId": qItems.ItemID[i],
"name": item.ItemName, "name": qItems.ItemName[i],
"description": isNull(item.ItemDescription) ? "" : item.ItemDescription, "description": isNull(qItems.ItemDescription[i]) ? "" : qItems.ItemDescription[i],
"price": item.ItemPrice, "price": qItems.ItemPrice[i],
"imageUrl": javaCast("null", ""), "imageUrl": javaCast("null", ""),
"photoTaskId": javaCast("null", ""), "photoTaskId": javaCast("null", ""),
"modifiers": itemModifiers, "modifiers": itemModifiers,
"sortOrder": item.ItemSortOrder "sortOrder": qItems.ItemSortOrder[i]
}); });
} }
// Build categories array // Build categories array
categories = []; categories = [];
catIndex = 0; catIndex = 0;
for (cat in qCategories) { for (i = 1; i <= qCategories.recordCount; i++) {
catID = cat.CategoryID; catID = qCategories.CategoryID[i];
catItems = structKeyExists(itemsByCategory, catID) ? itemsByCategory[catID] : []; catItems = structKeyExists(itemsByCategory, catID) ? itemsByCategory[catID] : [];
arrayAppend(categories, { arrayAppend(categories, {
"id": "cat_" & cat.CategoryID, "id": "cat_" & qCategories.CategoryID[i],
"dbId": cat.CategoryID, "dbId": qCategories.CategoryID[i],
"name": cat.CategoryName, "name": qCategories.CategoryName[i],
"description": "", "description": "",
"sortOrder": catIndex, "sortOrder": catIndex,
"items": catItems "items": catItems
@ -405,7 +339,7 @@ try {
catIndex++; catIndex++;
} }
// Build template library array for the UI // Build template library array
templateLibrary = []; templateLibrary = [];
for (templateID in templatesById) { for (templateID in templatesById) {
arrayAppend(templateLibrary, templatesById[templateID]); arrayAppend(templateLibrary, templatesById[templateID]);
@ -416,7 +350,7 @@ try {
response["TEMPLATES"] = templateLibrary; response["TEMPLATES"] = templateLibrary;
response["CATEGORY_COUNT"] = arrayLen(categories); response["CATEGORY_COUNT"] = arrayLen(categories);
response["TEMPLATE_COUNT"] = arrayLen(templateLibrary); response["TEMPLATE_COUNT"] = arrayLen(templateLibrary);
response["SCHEMA"] = newSchemaActive ? "unified" : "legacy"; response["SCHEMA"] = hasCategoriesData ? "legacy" : "unified";
totalItems = 0; totalItems = 0;
for (cat in categories) { for (cat in categories) {
@ -427,6 +361,7 @@ try {
} catch (any e) { } catch (any e) {
response["ERROR"] = "server_error"; response["ERROR"] = "server_error";
response["MESSAGE"] = e.message; response["MESSAGE"] = e.message;
response["DETAIL"] = e.detail ?: "";
} }
writeOutput(serializeJSON(response)); writeOutput(serializeJSON(response));

View file

@ -95,62 +95,51 @@
", [ { 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 ---> <!--- Get order type --->
<cfset qOrderDetails = queryExecute(" <cfset qOrderDetails = queryExecute("
SELECT o.OrderTypeID, a.AddressLine1, a.AddressCity SELECT o.OrderTypeID
FROM Orders o FROM Orders o
LEFT JOIN Addresses a ON a.AddressID = o.OrderAddressID
WHERE o.OrderID = ? WHERE o.OrderID = ?
", [ { value = OrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })> ", [ { value = OrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
<cfset orderTypeID = qOrderDetails.recordCount GT 0 ? val(qOrderDetails.OrderTypeID) : 1> <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 ---> <!--- OrderTypeID: 1=dine-in, 2=takeaway, 3=delivery --->
<cfswitch expression="#orderTypeID#"> <!--- Only create food running tasks for dine-in orders for now --->
<cfcase value="2"> <!--- TODO: Takeaway will have optional pickup counter service point --->
<!--- Takeaway: Staff prepares for customer pickup ---> <!--- TODO: Delivery will have GPS service point of delivery address --->
<cfset taskTitle = "Prepare Order ###OrderID# for customer pickup"> <cfif orderTypeID EQ 1>
<cfset taskCategoryID = 2> <!--- Dine-in: Server delivers to service point --->
</cfcase> <cfset tableName = len(qOrder.ServicePointName) ? qOrder.ServicePointName : "Table">
<cfcase value="3"> <cfset taskTitle = "Deliver Order ###OrderID# to " & tableName>
<!--- Delivery: Staff prepares for delivery driver pickup ---> <cfset taskCategoryID = 3>
<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, TaskCategoryID,
TaskTitle, 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 = taskCategoryID, cfsqltype = "cf_sql_integer" },
{ value = taskTitle, cfsqltype = "cf_sql_varchar" } { value = taskTitle, cfsqltype = "cf_sql_varchar" }
], { datasource = "payfrit" })> ], { datasource = "payfrit" })>
<cfset taskCreated = true> <cfset taskCreated = true>
</cfif>
</cfif> </cfif>
<cfcatch> <cfcatch>
<!--- Task creation failed, but don't fail the status update ---> <!--- Task creation failed, but don't fail the status update --->

View file

@ -162,6 +162,9 @@ try {
required = structKeyExists(tmpl, "required") && tmpl.required == true; required = structKeyExists(tmpl, "required") && tmpl.required == true;
options = structKeyExists(tmpl, "options") ? tmpl.options : []; options = structKeyExists(tmpl, "options") ? tmpl.options : [];
// Debug: Log options info
response.steps.append("Template '" & tmplName & "' has " & arrayLen(options) & " options (type: " & (isArray(options) ? "array" : "other") & ")");
// Check if template already exists for this business // Check if template already exists for this business
qTmpl = queryExecute(" qTmpl = queryExecute("
SELECT i.ItemID FROM Items i SELECT i.ItemID FROM Items i
@ -199,7 +202,8 @@ try {
// Create/update template options // Create/update template options
optionOrder = 1; optionOrder = 1;
for (opt in options) { for (j = 1; j <= arrayLen(options); j++) {
opt = options[j];
// Safety check: ensure opt is a struct with a name // Safety check: ensure opt is a struct with a name
if (!isStruct(opt) || !structKeyExists(opt, "name") || !len(opt.name)) { if (!isStruct(opt) || !structKeyExists(opt, "name") || !len(opt.name)) {
continue; continue;
@ -240,7 +244,8 @@ try {
response.steps.append("Processing " & arrayLen(categories) & " categories..."); response.steps.append("Processing " & arrayLen(categories) & " categories...");
catOrder = 1; catOrder = 1;
for (cat in categories) { for (c = 1; c <= arrayLen(categories); c++) {
cat = categories[c];
catName = cat.name; catName = cat.name;
// Check if category exists in Categories table // Check if category exists in Categories table
@ -286,7 +291,8 @@ try {
// Track item order within each category // Track item order within each category
categoryItemOrder = {}; categoryItemOrder = {};
for (item in items) { for (n = 1; n <= arrayLen(items); n++) {
item = items[n];
itemName = item.name; itemName = item.name;
itemDesc = structKeyExists(item, "description") ? item.description : ""; itemDesc = structKeyExists(item, "description") ? item.description : "";
itemPrice = structKeyExists(item, "price") ? val(item.price) : 0; itemPrice = structKeyExists(item, "price") ? val(item.price) : 0;
@ -356,7 +362,8 @@ try {
// Link modifier templates to this item // Link modifier templates to this item
modOrder = 1; modOrder = 1;
for (modName in itemModifiers) { for (m = 1; m <= arrayLen(itemModifiers); m++) {
modName = itemModifiers[m];
if (structKeyExists(templateMap, modName)) { if (structKeyExists(templateMap, modName)) {
templateItemID = templateMap[modName]; templateItemID = templateMap[modName];

View file

@ -841,6 +841,70 @@
<!-- Toast Container --> <!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div> <div class="toast-container" id="toastContainer"></div>
<!-- Image Preview Modal -->
<div id="imagePreviewModal" class="image-modal" onclick="closeImagePreview(event)">
<div class="image-modal-content">
<span class="image-modal-close" onclick="closeImagePreview()">&times;</span>
<img id="imagePreviewImg" src="" alt="Menu Image">
<div class="image-modal-caption" id="imagePreviewCaption"></div>
</div>
</div>
<style>
.image-modal {
display: none;
position: fixed;
z-index: 9999;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.9);
justify-content: center;
align-items: center;
}
.image-modal.active {
display: flex;
}
.image-modal-content {
position: relative;
max-width: 90%;
max-height: 90%;
}
.image-modal-content img {
max-width: 100%;
max-height: 85vh;
object-fit: contain;
border-radius: 8px;
}
.image-modal-close {
position: absolute;
top: -40px;
right: 0;
color: white;
font-size: 32px;
cursor: pointer;
padding: 5px 10px;
}
.image-modal-close:hover {
color: #ccc;
}
.image-modal-caption {
color: white;
text-align: center;
padding: 10px;
font-size: 14px;
}
.source-badge.clickable {
cursor: pointer;
text-decoration: underline;
color: var(--primary);
}
.source-badge.clickable:hover {
color: var(--primary-hover);
}
</style>
<script> <script>
// Configuration // Configuration
const config = { const config = {
@ -853,9 +917,55 @@
modifiers: [], modifiers: [],
items: [] items: []
}, },
currentStep: 1 currentStep: 1,
imageObjectUrls: [] // Store object URLs for uploaded images
}; };
// Image preview functions
function showImagePreview(imageIndex) {
// imageIndex is 1-based from the API
const fileIndex = imageIndex - 1;
if (fileIndex < 0 || fileIndex >= config.uploadedFiles.length) {
console.error('Invalid image index:', imageIndex);
return;
}
const file = config.uploadedFiles[fileIndex];
// Create object URL if not cached
if (!config.imageObjectUrls[fileIndex]) {
config.imageObjectUrls[fileIndex] = URL.createObjectURL(file);
}
const modal = document.getElementById('imagePreviewModal');
const img = document.getElementById('imagePreviewImg');
const caption = document.getElementById('imagePreviewCaption');
img.src = config.imageObjectUrls[fileIndex];
caption.textContent = `Image ${imageIndex}: ${file.name}`;
modal.classList.add('active');
// Prevent body scroll
document.body.style.overflow = 'hidden';
}
function closeImagePreview(event) {
// If event is passed, only close if clicking the background (not the image)
if (event && event.target.id !== 'imagePreviewModal') {
return;
}
const modal = document.getElementById('imagePreviewModal');
modal.classList.remove('active');
document.body.style.overflow = '';
}
// Close on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeImagePreview();
}
});
// Initialize // Initialize
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initializeConfig(); initializeConfig();
@ -1523,7 +1633,10 @@
} }
let modifiersHtml = modifiers.map((mod, i) => { let modifiersHtml = modifiers.map((mod, i) => {
const sourceImg = mod.sourceImageIndex ? `Image ${mod.sourceImageIndex}` : 'Unknown source'; const sourceImgIndex = mod.sourceImageIndex;
const sourceImgBadge = sourceImgIndex
? `<span class="source-badge clickable" onclick="event.stopPropagation(); showImagePreview(${sourceImgIndex})">Image ${sourceImgIndex}</span>`
: '<span class="source-badge">Unknown source</span>';
const appliesToInfo = mod.appliesTo === 'category' && mod.categoryName const appliesToInfo = mod.appliesTo === 'category' && mod.categoryName
? `<span class="applies-to-badge">Applies to: ${mod.categoryName}</span>` ? `<span class="applies-to-badge">Applies to: ${mod.categoryName}</span>`
: mod.appliesTo === 'uncertain' : mod.appliesTo === 'uncertain'
@ -1548,7 +1661,7 @@
<span class="modifier-type">${mod.required ? 'Required' : 'Optional'}</span> <span class="modifier-type">${mod.required ? 'Required' : 'Optional'}</span>
</div> </div>
<div class="modifier-meta"> <div class="modifier-meta">
<span class="source-badge">${sourceImg}</span> ${sourceImgBadge}
${appliesToInfo} ${appliesToInfo}
<span class="options-count">${optionsCount} option${optionsCount !== 1 ? 's' : ''}</span> <span class="options-count">${optionsCount} option${optionsCount !== 1 ? 's' : ''}</span>
</div> </div>
@ -1647,7 +1760,10 @@
const modifier = uncertainModifiers[currentIndex]; const modifier = uncertainModifiers[currentIndex];
// Build detailed modifier view // Build detailed modifier view
const sourceImg = modifier.sourceImageIndex ? `Image ${modifier.sourceImageIndex}` : 'Unknown source'; const sourceImgIndex = modifier.sourceImageIndex;
const sourceImgBadge = sourceImgIndex
? `<span class="source-badge clickable" onclick="showImagePreview(${sourceImgIndex})">Image ${sourceImgIndex}</span>`
: '<span class="source-badge">Unknown source</span>';
const optionsCount = (modifier.options || []).length; const optionsCount = (modifier.options || []).length;
const optionsList = (modifier.options || []).filter(opt => opt && opt.name).map(opt => ` const optionsList = (modifier.options || []).filter(opt => opt && opt.name).map(opt => `
<div class="modifier-option-detail"> <div class="modifier-option-detail">
@ -1669,7 +1785,7 @@
<div class="modifier-details-view" style="background: var(--gray-50); border: 1px solid var(--gray-200); border-radius: 8px; padding: 16px; margin: 16px 0;"> <div class="modifier-details-view" style="background: var(--gray-50); border: 1px solid var(--gray-200); border-radius: 8px; padding: 16px; margin: 16px 0;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<span style="font-weight: 500;">${modifier.name}</span> <span style="font-weight: 500;">${modifier.name}</span>
<span class="source-badge">${sourceImg}</span> ${sourceImgBadge}
</div> </div>
<div style="color: var(--gray-600); font-size: 14px; margin-bottom: 8px;"> <div style="color: var(--gray-600); font-size: 14px; margin-bottom: 8px;">
<strong>${optionsCount} option${optionsCount !== 1 ? 's' : ''}</strong> • ${modifier.required ? 'Required' : 'Optional'} <strong>${optionsCount} option${optionsCount !== 1 ? 's' : ''}</strong> • ${modifier.required ? 'Required' : 'Optional'}