- saveCategory.cfm: Accept ParentCategoryID, enforce max 2-level nesting - items.cfm: Include ParentCategoryID on virtual category rows for Android - getForBuilder.cfm: Return ParentCategoryID in builder API response - saveFromBuilder.cfm: Persist ParentCategoryID on save, track JS-to-DB id mapping - saveWizard.cfm: Two-pass category creation (parents first, then subcategories) - menu-builder.html: Parent category dropdown in properties, visual nesting in canvas, add subcategory button, renderItemCard() extracted for reuse Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
503 lines
19 KiB
Text
503 lines
19 KiB
Text
<cfsetting showdebugoutput="false">
|
|
<cfsetting enablecfoutputonly="true">
|
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
|
<cfheader name="Cache-Control" value="no-store">
|
|
|
|
<cfscript>
|
|
/**
|
|
* Get Menu for Builder
|
|
* Returns categories and items in structured format for the menu builder UI
|
|
*/
|
|
|
|
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.Name[i],
|
|
"price": allOptions.Price[i],
|
|
"isDefault": allOptions.IsDefault[i] == 1 ? true : false,
|
|
"sortOrder": allOptions.SortOrder[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 {
|
|
requestBody = toString(getHttpRequestData().content);
|
|
requestData = {};
|
|
if (len(requestBody)) {
|
|
requestData = deserializeJSON(requestBody);
|
|
}
|
|
|
|
businessID = 0;
|
|
if (structKeyExists(requestData, "BusinessID")) {
|
|
businessID = val(requestData.BusinessID);
|
|
}
|
|
|
|
if (businessID == 0) {
|
|
response["ERROR"] = "missing_business_id";
|
|
response["MESSAGE"] = "BusinessID is required";
|
|
writeOutput(serializeJSON(response));
|
|
abort;
|
|
}
|
|
|
|
// Check for MenuID filter (optional - if provided, only return categories for that menu)
|
|
menuID = structKeyExists(requestData, "MenuID") ? val(requestData.MenuID) : 0;
|
|
|
|
// Get business's default menu setting and timezone
|
|
defaultMenuID = 0;
|
|
businessTimezone = "America/Los_Angeles";
|
|
try {
|
|
qBiz = queryTimed("SELECT DefaultMenuID, Timezone FROM Businesses WHERE ID = :businessID",
|
|
{ businessID: businessID }, { datasource: "payfrit" });
|
|
if (qBiz.recordCount > 0) {
|
|
if (!isNull(qBiz.DefaultMenuID)) defaultMenuID = val(qBiz.DefaultMenuID);
|
|
if (!isNull(qBiz.Timezone) && len(trim(qBiz.Timezone))) businessTimezone = qBiz.Timezone;
|
|
}
|
|
} catch (any e) {}
|
|
|
|
// Get all menus for this business
|
|
allMenus = [];
|
|
try {
|
|
qMenus = queryTimed("
|
|
SELECT ID, Name, Description, DaysActive,
|
|
StartTime, EndTime, SortOrder
|
|
FROM Menus
|
|
WHERE BusinessID = :businessID AND IsActive = 1
|
|
ORDER BY SortOrder, Name
|
|
", { businessID: businessID }, { datasource: "payfrit" });
|
|
|
|
for (m = 1; m <= qMenus.recordCount; m++) {
|
|
arrayAppend(allMenus, {
|
|
"MenuID": qMenus.ID[m],
|
|
"MenuName": qMenus.Name[m],
|
|
"MenuDescription": isNull(qMenus.Description[m]) ? "" : qMenus.Description[m],
|
|
"MenuDaysActive": qMenus.DaysActive[m],
|
|
"MenuStartTime": isNull(qMenus.StartTime[m]) ? "" : timeFormat(qMenus.StartTime[m], "HH:mm"),
|
|
"MenuEndTime": isNull(qMenus.EndTime[m]) ? "" : timeFormat(qMenus.EndTime[m], "HH:mm"),
|
|
"SortOrder": qMenus.SortOrder[m]
|
|
});
|
|
}
|
|
|
|
// Auto-select menu based on current time when no specific menu requested
|
|
if (menuID == 0 && qMenus.recordCount > 1) {
|
|
currentTime = left(getTimeInZone(businessTimezone), 5); // "HH:mm"
|
|
currentDay = getDayInZone(businessTimezone); // 1=Sun, 2=Mon, ... 7=Sat
|
|
dayBit = 2 ^ (currentDay - 1); // bitmask: 1=Sun, 2=Mon, 4=Tue, etc.
|
|
activeMenuIds = [];
|
|
|
|
for (m = 1; m <= qMenus.recordCount; m++) {
|
|
// Check if menu is active today (days bitmask)
|
|
menuDays = qMenus.DaysActive[m];
|
|
if (bitAnd(menuDays, dayBit) == 0) continue;
|
|
|
|
// Check time range
|
|
hasStart = !isNull(qMenus.StartTime[m]);
|
|
hasEnd = !isNull(qMenus.EndTime[m]);
|
|
if (hasStart && hasEnd) {
|
|
startT = timeFormat(qMenus.StartTime[m], "HH:mm");
|
|
endT = timeFormat(qMenus.EndTime[m], "HH:mm");
|
|
if (currentTime >= startT && currentTime <= endT) {
|
|
arrayAppend(activeMenuIds, qMenus.ID[m]);
|
|
}
|
|
} else {
|
|
// No time restriction = always active
|
|
arrayAppend(activeMenuIds, qMenus.ID[m]);
|
|
}
|
|
}
|
|
|
|
// If exactly one menu is active now, auto-select it
|
|
if (arrayLen(activeMenuIds) == 1) {
|
|
menuID = activeMenuIds[1];
|
|
} else if (arrayLen(activeMenuIds) > 1 && defaultMenuID > 0 && arrayFind(activeMenuIds, defaultMenuID)) {
|
|
// Multiple menus active - use business default if it's among the active ones
|
|
menuID = defaultMenuID;
|
|
}
|
|
// If multiple match with no default, or none match, show all (menuID stays 0)
|
|
}
|
|
} catch (any e) {
|
|
// Menus table might not exist yet
|
|
}
|
|
|
|
// Check if Categories table has data for this business
|
|
hasCategoriesData = false;
|
|
try {
|
|
qCatCheck = queryTimed("
|
|
SELECT 1 FROM Categories WHERE BusinessID = :businessID LIMIT 1
|
|
", { businessID: businessID }, { datasource: "payfrit" });
|
|
hasCategoriesData = (qCatCheck.recordCount > 0);
|
|
} catch (any e) {
|
|
hasCategoriesData = false;
|
|
}
|
|
|
|
if (hasCategoriesData) {
|
|
// OLD SCHEMA: Use Categories table for categories
|
|
// Build menu filter clause
|
|
menuFilter = "";
|
|
menuParams = { businessID: businessID };
|
|
if (menuID > 0) {
|
|
menuFilter = " AND MenuID = :menuID";
|
|
menuParams["menuID"] = menuID;
|
|
}
|
|
|
|
qCategories = queryTimed("
|
|
SELECT
|
|
ID,
|
|
Name,
|
|
ParentCategoryID,
|
|
SortOrder as SortOrder,
|
|
MenuID
|
|
FROM Categories
|
|
WHERE BusinessID = :businessID #menuFilter#
|
|
ORDER BY SortOrder, Name
|
|
", menuParams, { datasource: "payfrit" });
|
|
|
|
// Get menu items - items that belong to categories (not modifiers)
|
|
qItems = queryTimed("
|
|
SELECT
|
|
i.ID,
|
|
i.CategoryID as CategoryItemID,
|
|
i.Name,
|
|
i.Description,
|
|
i.Price,
|
|
i.SortOrder,
|
|
i.IsActive
|
|
FROM Items i
|
|
WHERE i.BusinessID = :businessID
|
|
AND i.IsActive = 1
|
|
AND i.CategoryID > 0
|
|
ORDER BY i.SortOrder, i.Name
|
|
", { businessID: businessID }, { datasource: "payfrit" });
|
|
|
|
// Get direct modifiers (items with ParentItemID pointing to menu items, not categories)
|
|
qDirectModifiers = queryTimed("
|
|
SELECT
|
|
m.ID as ItemID,
|
|
m.ParentItemID as ParentItemID,
|
|
m.Name,
|
|
m.Price,
|
|
m.IsCheckedByDefault as IsDefault,
|
|
m.SortOrder,
|
|
m.RequiresChildSelection as RequiresSelection,
|
|
m.MaxNumSelectionReq as MaxSelections
|
|
FROM Items m
|
|
WHERE m.BusinessID = :businessID
|
|
AND m.IsActive = 1
|
|
AND m.ParentItemID > 0
|
|
AND (m.CategoryID = 0 OR m.CategoryID IS NULL)
|
|
ORDER BY m.SortOrder, m.Name
|
|
", { businessID: businessID }, { datasource: "payfrit" });
|
|
|
|
} else {
|
|
// NEW UNIFIED SCHEMA: Categories are Items at ParentID=0 with children
|
|
qCategories = queryTimed("
|
|
SELECT DISTINCT
|
|
p.ID as CategoryID,
|
|
p.Name as Name,
|
|
p.SortOrder
|
|
FROM Items p
|
|
INNER JOIN Items c ON c.ParentItemID = p.ID
|
|
WHERE p.BusinessID = :businessID
|
|
AND p.ParentItemID = 0
|
|
AND p.IsActive = 1
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM lt_ItemID_TemplateItemID tl WHERE tl.TemplateItemID = p.ID
|
|
)
|
|
ORDER BY p.SortOrder, p.Name
|
|
", { businessID: businessID }, { datasource: "payfrit" });
|
|
|
|
qItems = queryTimed("
|
|
SELECT
|
|
i.ID,
|
|
i.ParentItemID as CategoryItemID,
|
|
i.Name,
|
|
i.Description,
|
|
i.Price,
|
|
i.SortOrder,
|
|
i.IsActive
|
|
FROM Items i
|
|
INNER JOIN Items cat ON cat.ID = i.ParentItemID
|
|
WHERE i.BusinessID = :businessID
|
|
AND i.IsActive = 1
|
|
AND cat.ParentItemID = 0
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM lt_ItemID_TemplateItemID tl WHERE tl.TemplateItemID = cat.ID
|
|
)
|
|
ORDER BY i.SortOrder, i.Name
|
|
", { businessID: businessID }, { datasource: "payfrit" });
|
|
|
|
qDirectModifiers = queryTimed("
|
|
SELECT
|
|
m.ID as ItemID,
|
|
m.ParentItemID as ParentItemID,
|
|
m.Name,
|
|
m.Price,
|
|
m.IsCheckedByDefault as IsDefault,
|
|
m.SortOrder,
|
|
m.RequiresChildSelection as RequiresSelection,
|
|
m.MaxNumSelectionReq as MaxSelections
|
|
FROM Items m
|
|
WHERE m.BusinessID = :businessID
|
|
AND m.IsActive = 1
|
|
AND m.ParentItemID > 0
|
|
ORDER BY m.SortOrder, m.Name
|
|
", { businessID: businessID }, { datasource: "payfrit" });
|
|
}
|
|
|
|
// Collect menu item IDs for filtering template links
|
|
menuItemIds = [];
|
|
for (i = 1; i <= qItems.recordCount; i++) {
|
|
arrayAppend(menuItemIds, qItems.ID[i]);
|
|
}
|
|
|
|
// Get template links ONLY for this business's menu items
|
|
qTemplateLinks = queryNew("ParentItemID,TemplateItemID,SortOrder");
|
|
if (arrayLen(menuItemIds) > 0) {
|
|
qTemplateLinks = queryTimed("
|
|
SELECT
|
|
tl.ItemID as ParentItemID,
|
|
tl.TemplateItemID,
|
|
tl.SortOrder
|
|
FROM lt_ItemID_TemplateItemID 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 = queryTimed("
|
|
SELECT DISTINCT
|
|
t.ID as ItemID,
|
|
t.Name,
|
|
t.Price,
|
|
t.IsCheckedByDefault as IsDefault,
|
|
t.SortOrder,
|
|
t.RequiresChildSelection as RequiresSelection,
|
|
t.MaxNumSelectionReq as MaxSelections
|
|
FROM Items t
|
|
WHERE t.BusinessID = :businessID
|
|
AND (t.CategoryID = 0 OR t.CategoryID IS NULL)
|
|
AND t.ParentItemID = 0
|
|
AND t.IsActive = 1
|
|
ORDER BY t.SortOrder, t.Name
|
|
", { 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,Name,Price,IsDefault,SortOrder,RequiresSelection,MaxSelections");
|
|
if (arrayLen(templateIds) > 0) {
|
|
qTemplateChildren = queryTimed("
|
|
SELECT
|
|
c.ID as ItemID,
|
|
c.ParentItemID as ParentItemID,
|
|
c.Name,
|
|
c.Price,
|
|
c.IsCheckedByDefault as IsDefault,
|
|
c.SortOrder,
|
|
c.RequiresChildSelection as RequiresSelection,
|
|
c.MaxNumSelectionReq as MaxSelections
|
|
FROM Items c
|
|
WHERE c.ParentItemID IN (:templateIds)
|
|
AND c.IsActive = 1
|
|
ORDER BY c.SortOrder, c.Name
|
|
", { templateIds: { value: arrayToList(templateIds), cfsqltype: "cf_sql_varchar", list: true } }, { datasource: "payfrit" });
|
|
}
|
|
|
|
// Build templates lookup with their options
|
|
templatesById = {};
|
|
for (i = 1; i <= qTemplates.recordCount; i++) {
|
|
templateID = qTemplates.ItemID[i];
|
|
options = buildOptionsTree(qTemplateChildren, templateID);
|
|
templatesById[templateID] = {
|
|
"id": "mod_" & qTemplates.ItemID[i],
|
|
"dbId": qTemplates.ItemID[i],
|
|
"name": qTemplates.Name[i],
|
|
"price": qTemplates.Price[i],
|
|
"isDefault": qTemplates.IsDefault[i] == 1 ? true : false,
|
|
"sortOrder": qTemplates.SortOrder[i],
|
|
"isTemplate": true,
|
|
"requiresSelection": isNull(qTemplates.RequiresSelection[i]) ? false : (qTemplates.RequiresSelection[i] == 1),
|
|
"maxSelections": isNull(qTemplates.MaxSelections[i]) ? 0 : qTemplates.MaxSelections[i],
|
|
"options": options
|
|
};
|
|
}
|
|
|
|
// Build template links lookup by parent ItemID
|
|
templateLinksByItem = {};
|
|
for (i = 1; i <= qTemplateLinks.recordCount; i++) {
|
|
parentID = qTemplateLinks.ParentItemID[i];
|
|
templateID = qTemplateLinks.TemplateItemID[i];
|
|
|
|
if (!structKeyExists(templateLinksByItem, parentID)) {
|
|
templateLinksByItem[parentID] = [];
|
|
}
|
|
|
|
if (structKeyExists(templatesById, templateID)) {
|
|
tmpl = duplicate(templatesById[templateID]);
|
|
tmpl["sortOrder"] = qTemplateLinks.SortOrder[i];
|
|
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
|
|
itemsByCategory = {};
|
|
for (i = 1; i <= qItems.recordCount; i++) {
|
|
catID = qItems.CategoryItemID[i];
|
|
if (!structKeyExists(itemsByCategory, catID)) {
|
|
itemsByCategory[catID] = [];
|
|
}
|
|
|
|
itemID = qItems.ID[i];
|
|
|
|
// 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;
|
|
});
|
|
}
|
|
|
|
// Check for existing item photo
|
|
itemImageUrl = "";
|
|
itemsDir = expandPath("/uploads/items");
|
|
for (ext in ["jpg","jpeg","png","gif","webp","JPG","JPEG","PNG","GIF","WEBP"]) {
|
|
if (fileExists(itemsDir & "/" & qItems.ID[i] & "." & ext)) {
|
|
itemImageUrl = "/uploads/items/" & qItems.ID[i] & "." & ext;
|
|
break;
|
|
}
|
|
}
|
|
|
|
arrayAppend(itemsByCategory[catID], {
|
|
"id": "item_" & qItems.ID[i],
|
|
"dbId": qItems.ID[i],
|
|
"name": qItems.Name[i],
|
|
"description": isNull(qItems.Description[i]) ? "" : qItems.Description[i],
|
|
"price": qItems.Price[i],
|
|
"imageUrl": len(itemImageUrl) ? itemImageUrl : javaCast("null", ""),
|
|
"photoTaskId": javaCast("null", ""),
|
|
"modifiers": itemModifiers,
|
|
"sortOrder": qItems.SortOrder[i]
|
|
});
|
|
}
|
|
|
|
// Build categories array
|
|
categories = [];
|
|
catIndex = 0;
|
|
for (i = 1; i <= qCategories.recordCount; i++) {
|
|
catID = qCategories.ID[i];
|
|
catItems = structKeyExists(itemsByCategory, catID) ? itemsByCategory[catID] : [];
|
|
|
|
catStruct = {
|
|
"id": "cat_" & qCategories.ID[i],
|
|
"dbId": qCategories.ID[i],
|
|
"name": qCategories.Name[i],
|
|
"description": "",
|
|
"sortOrder": catIndex,
|
|
"items": catItems
|
|
};
|
|
|
|
// Include MenuID and ParentCategoryID if available (legacy schema with Categories table)
|
|
if (hasCategoriesData) {
|
|
try {
|
|
catStruct["menuId"] = isNull(qCategories.MenuID[i]) ? 0 : val(qCategories.MenuID[i]);
|
|
} catch (any e) {
|
|
catStruct["menuId"] = 0;
|
|
}
|
|
try {
|
|
catStruct["parentCategoryId"] = isNull(qCategories.ParentCategoryID[i]) ? 0 : val(qCategories.ParentCategoryID[i]);
|
|
catStruct["parentCategoryDbId"] = catStruct["parentCategoryId"];
|
|
} catch (any e) {
|
|
catStruct["parentCategoryId"] = 0;
|
|
catStruct["parentCategoryDbId"] = 0;
|
|
}
|
|
}
|
|
|
|
arrayAppend(categories, catStruct);
|
|
catIndex++;
|
|
}
|
|
|
|
// Build template library array
|
|
templateLibrary = [];
|
|
for (templateID in templatesById) {
|
|
arrayAppend(templateLibrary, templatesById[templateID]);
|
|
}
|
|
|
|
// Get business brand color
|
|
brandColor = "";
|
|
try {
|
|
qBrand = queryTimed("
|
|
SELECT BrandColor AS BusinessBrandColor FROM Businesses WHERE ID = :bizId
|
|
", { bizId: businessID }, { datasource: "payfrit" });
|
|
if (qBrand.recordCount > 0 && len(trim(qBrand.BusinessBrandColor))) {
|
|
brandColor = left(qBrand.BusinessBrandColor, 1) == chr(35) ? qBrand.BusinessBrandColor : chr(35) & qBrand.BusinessBrandColor;
|
|
}
|
|
} catch (any e) {
|
|
// Column may not exist yet, ignore
|
|
}
|
|
|
|
response["OK"] = true;
|
|
response["MENU"] = { "categories": categories };
|
|
response["MENUS"] = allMenus;
|
|
response["SELECTED_MENU_ID"] = menuID;
|
|
response["DEFAULT_MENU_ID"] = defaultMenuID;
|
|
response["TEMPLATES"] = templateLibrary;
|
|
response["BRANDCOLOR"] = brandColor;
|
|
response["CATEGORY_COUNT"] = arrayLen(categories);
|
|
response["TEMPLATE_COUNT"] = arrayLen(templateLibrary);
|
|
response["MENU_COUNT"] = arrayLen(allMenus);
|
|
response["SCHEMA"] = hasCategoriesData ? "legacy" : "unified";
|
|
|
|
totalItems = 0;
|
|
for (cat in categories) {
|
|
totalItems += arrayLen(cat.items);
|
|
}
|
|
response["ITEM_COUNT"] = totalItems;
|
|
|
|
} catch (any e) {
|
|
response["ERROR"] = "server_error";
|
|
response["MESSAGE"] = e.message;
|
|
response["DETAIL"] = e.detail ?: "";
|
|
}
|
|
|
|
try{logPerf(0);}catch(any e){}
|
|
writeOutput(serializeJSON(response));
|
|
</cfscript>
|