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,32 +57,19 @@ 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;
try {
qCheck = queryExecute("
SELECT COUNT(*) as cnt FROM Items
WHERE ItemBusinessID = :businessID AND ItemBusinessID > 0
", { businessID: businessID }, { datasource: "payfrit" });
newSchemaActive = (qCheck.cnt > 0);
} catch (any e) {
newSchemaActive = false;
}
if (newSchemaActive) {
// NEW SCHEMA: Check if Categories table has data for this business
hasCategoriesData = false; hasCategoriesData = false;
try { try {
qCatCheck = queryExecute(" qCatCheck = queryExecute("
SELECT COUNT(*) as cnt FROM Categories WHERE CategoryBusinessID = :businessID SELECT 1 FROM Categories WHERE CategoryBusinessID = :businessID LIMIT 1
", { businessID: businessID }, { datasource: "payfrit" }); ", { businessID: businessID }, { datasource: "payfrit" });
hasCategoriesData = (qCatCheck.cnt > 0); hasCategoriesData = (qCatCheck.recordCount > 0);
} catch (any e) { } catch (any e) {
hasCategoriesData = false; hasCategoriesData = false;
} }
if (hasCategoriesData) { if (hasCategoriesData) {
// Use Categories table // OLD SCHEMA: Use Categories table for categories
qCategories = queryExecute(" qCategories = queryExecute("
SELECT SELECT
CategoryID, CategoryID,
@ -75,7 +80,7 @@ try {
ORDER BY CategorySortOrder, CategoryName ORDER BY CategorySortOrder, CategoryName
", { businessID: businessID }, { datasource: "payfrit" }); ", { businessID: businessID }, { datasource: "payfrit" });
// Get menu items with CategoryID // Get menu items - items that belong to categories (not modifiers)
qItems = queryExecute(" qItems = queryExecute("
SELECT SELECT
i.ItemID, i.ItemID,
@ -89,13 +94,30 @@ try {
WHERE i.ItemBusinessID = :businessID WHERE i.ItemBusinessID = :businessID
AND i.ItemIsActive = 1 AND i.ItemIsActive = 1
AND i.ItemCategoryID > 0 AND i.ItemCategoryID > 0
AND NOT EXISTS (
SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID
)
ORDER BY i.ItemSortOrder, i.ItemName ORDER BY i.ItemSortOrder, i.ItemName
", { businessID: businessID }, { datasource: "payfrit" }); ", { businessID: businessID }, { datasource: "payfrit" });
// Get direct modifiers (items with ParentItemID pointing to menu items, not categories)
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
AND (m.ItemCategoryID = 0 OR m.ItemCategoryID IS NULL)
ORDER BY m.ItemSortOrder, m.ItemName
", { businessID: businessID }, { datasource: "payfrit" });
} else { } else {
// Fallback: Categories are Items at ParentID=0 with children (not in ItemTemplateLinks) // NEW UNIFIED SCHEMA: Categories are Items at ParentID=0 with children
qCategories = queryExecute(" qCategories = queryExecute("
SELECT DISTINCT SELECT DISTINCT
p.ItemID as CategoryID, p.ItemID as CategoryID,
@ -112,7 +134,6 @@ try {
ORDER BY p.ItemSortOrder, p.ItemName ORDER BY p.ItemSortOrder, p.ItemName
", { businessID: businessID }, { datasource: "payfrit" }); ", { businessID: businessID }, { datasource: "payfrit" });
// Get all menu items (children of category Items, not templates)
qItems = queryExecute(" qItems = queryExecute("
SELECT SELECT
i.ItemID, i.ItemID,
@ -132,55 +153,46 @@ try {
) )
ORDER BY i.ItemSortOrder, i.ItemName ORDER BY i.ItemSortOrder, i.ItemName
", { businessID: businessID }, { datasource: "payfrit" }); ", { businessID: businessID }, { datasource: "payfrit" });
}
} else { qDirectModifiers = queryExecute("
// OLD SCHEMA: Use Categories table
qCategories = queryExecute("
SELECT SELECT
CategoryID, m.ItemID,
CategoryName, m.ItemParentItemID as ParentItemID,
0 as ItemSortOrder m.ItemName,
FROM Categories m.ItemPrice,
WHERE CategoryBusinessID = :businessID m.ItemIsCheckedByDefault as IsDefault,
ORDER BY CategoryName m.ItemSortOrder,
", { businessID: businessID }, { datasource: "payfrit" }); m.ItemRequiresChildSelection as RequiresSelection,
m.ItemMaxNumSelectionReq as MaxSelections
qItems = queryExecute(" FROM Items m
SELECT WHERE m.ItemBusinessID = :businessID
i.ItemID, AND m.ItemIsActive = 1
i.ItemCategoryID as CategoryItemID, AND m.ItemParentItemID > 0
i.ItemName, ORDER BY m.ItemSortOrder, m.ItemName
i.ItemDescription,
i.ItemPrice,
i.ItemSortOrder,
i.ItemIsActive
FROM Items i
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID
WHERE c.CategoryBusinessID = :businessID
AND i.ItemIsActive = 1
AND i.ItemParentItemID = 0
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) // Collect menu item IDs for filtering template links
menuItemIds = [];
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(" qTemplateLinks = queryExecute("
SELECT SELECT
tl.ItemID as ParentItemID, tl.ItemID as ParentItemID,
tl.TemplateItemID, tl.TemplateItemID,
tl.SortOrder, tl.SortOrder
t.ItemName as TemplateName,
t.ItemPrice as TemplatePrice,
t.ItemIsCheckedByDefault as TemplateIsDefault
FROM ItemTemplateLinks tl FROM ItemTemplateLinks tl
INNER JOIN Items t ON t.ItemID = tl.TemplateItemID WHERE tl.ItemID IN (:itemIds)
ORDER BY tl.ItemID, tl.SortOrder ORDER BY tl.ItemID, tl.SortOrder
", {}, { datasource: "payfrit" }); ", { itemIds: { value: arrayToList(menuItemIds), cfsqltype: "cf_sql_varchar", list: true } }, { datasource: "payfrit" });
}
// Get all templates for this business // Get templates for this business only
// Templates are Items with ItemCategoryID=0 and ItemParentItemID=0
if (newSchemaActive) {
qTemplates = queryExecute(" qTemplates = queryExecute("
SELECT DISTINCT SELECT DISTINCT
t.ItemID, t.ItemID,
@ -192,36 +204,22 @@ try {
t.ItemMaxNumSelectionReq as MaxSelections t.ItemMaxNumSelectionReq as MaxSelections
FROM Items t FROM Items t
WHERE t.ItemBusinessID = :businessID WHERE t.ItemBusinessID = :businessID
AND t.ItemCategoryID = 0 AND (t.ItemCategoryID = 0 OR t.ItemCategoryID IS NULL)
AND t.ItemParentItemID = 0 AND t.ItemParentItemID = 0
AND t.ItemIsActive = 1 AND t.ItemIsActive = 1
ORDER BY t.ItemSortOrder, t.ItemName ORDER BY t.ItemSortOrder, t.ItemName
", { businessID: businessID }, { datasource: "payfrit" }); ", { businessID: businessID }, { datasource: "payfrit" });
} else {
qTemplates = queryExecute(" // Get template children (options within templates)
SELECT DISTINCT templateIds = [];
t.ItemID, for (i = 1; i <= qTemplates.recordCount; i++) {
t.ItemName, arrayAppend(templateIds, qTemplates.ItemID[i]);
t.ItemPrice,
t.ItemIsCheckedByDefault as IsDefault,
t.ItemSortOrder,
t.ItemRequiresChildSelection as RequiresSelection,
t.ItemMaxNumSelectionReq as MaxSelections
FROM Items t
INNER JOIN ItemTemplateLinks tl ON tl.TemplateItemID = t.ItemID
INNER JOIN Items i ON i.ItemID = tl.ItemID
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID
WHERE c.CategoryBusinessID = :businessID
AND t.ItemIsActive = 1
ORDER BY t.ItemSortOrder, t.ItemName
", { businessID: businessID }, { datasource: "payfrit" });
} }
// Get all children of templates (options within modifier groups) qTemplateChildren = queryNew("ItemID,ParentItemID,ItemName,ItemPrice,IsDefault,ItemSortOrder,RequiresSelection,MaxSelections");
// Get children of ALL templates for this business (not just linked ones) if (arrayLen(templateIds) > 0) {
if (newSchemaActive) {
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
FROM Items t
WHERE t.ItemBusinessID = :businessID
AND t.ItemCategoryID = 0
AND t.ItemParentItemID = 0
)
AND c.ItemIsActive = 1 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,36 +95,24 @@
", [ { 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>
</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 ---> <!--- Dine-in: Server delivers to service point --->
<cfset tableName = len(qOrder.ServicePointName) ? qOrder.ServicePointName : "Table"> <cfset tableName = len(qOrder.ServicePointName) ? qOrder.ServicePointName : "Table">
<cfset taskTitle = "Deliver Order ###OrderID# to " & tableName> <cfset taskTitle = "Deliver Order ###OrderID# to " & tableName>
<cfset taskCategoryID = 3> <cfset taskCategoryID = 3>
</cfdefaultcase>
</cfswitch>
<cfset queryExecute(" <cfset queryExecute("
INSERT INTO Tasks ( INSERT INTO Tasks (
@ -152,6 +140,7 @@
], { 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 --->
</cfcatch> </cfcatch>

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'}