From 3ccc82c9f2dd6e03f04ebaff8f83452b0a04b0b8 Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Sat, 28 Feb 2026 21:29:40 -0800 Subject: [PATCH] Add subcategory support (2 levels deep) - 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 --- api/menu/getForBuilder.cfm | 10 +- api/menu/items.cfm | 13 +- api/menu/saveCategory.cfm | 27 +++- api/menu/saveFromBuilder.cfm | 34 ++++- api/setup/saveWizard.cfm | 50 +++++++- portal/menu-builder.html | 240 ++++++++++++++++++++++++++--------- 6 files changed, 303 insertions(+), 71 deletions(-) diff --git a/api/menu/getForBuilder.cfm b/api/menu/getForBuilder.cfm index b92f7f8..0579387 100644 --- a/api/menu/getForBuilder.cfm +++ b/api/menu/getForBuilder.cfm @@ -160,6 +160,7 @@ try { SELECT ID, Name, + ParentCategoryID, SortOrder as SortOrder, MenuID FROM Categories @@ -434,13 +435,20 @@ try { "items": catItems }; - // Include MenuID if available (legacy schema with Categories table) + // 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); diff --git a/api/menu/items.cfm b/api/menu/items.cfm index 3e42797..84ba3d4 100644 --- a/api/menu/items.cfm +++ b/api/menu/items.cfm @@ -145,6 +145,7 @@ Name, SortOrder, OrderTypes, + ParentCategoryID, ScheduleStart, ScheduleEnd, ScheduleDays, @@ -323,17 +324,27 @@ - + + + + + + + + + 0 = subcategory (max 2 levels) * "ScheduleStart": "06:00:00", // Optional, null = always available * "ScheduleEnd": "11:00:00", // Optional * "ScheduleDays": "2,3,4,5,6" // Optional, 1=Sun..7=Sat, null = all days @@ -47,6 +48,25 @@ try { ? trim(data.ScheduleEnd) : javaCast("null", ""); ScheduleDays = structKeyExists(data, "ScheduleDays") && len(trim(data.ScheduleDays)) ? trim(data.ScheduleDays) : javaCast("null", ""); + ParentCategoryID = structKeyExists(data, "ParentCategoryID") ? val(data.ParentCategoryID) : 0; + + // Enforce 2-level max: if the proposed parent is itself a subcategory, reject + if (ParentCategoryID > 0) { + qParent = queryTimed("SELECT ParentCategoryID FROM Categories WHERE ID = :parentId", + { parentId: ParentCategoryID }, { datasource = "payfrit" }); + if (qParent.recordCount == 0) { + response["ERROR"] = "invalid_parent"; + response["MESSAGE"] = "Parent category not found"; + writeOutput(serializeJSON(response)); + return; + } + if (val(qParent.ParentCategoryID) > 0) { + response["ERROR"] = "nesting_too_deep"; + response["MESSAGE"] = "Subcategories cannot have their own subcategories (max 2 levels)"; + writeOutput(serializeJSON(response)); + return; + } + } if (CategoryID > 0) { // Update existing category @@ -55,6 +75,7 @@ try { Name = :name, SortOrder = :sortOrder, OrderTypes = :orderTypes, + ParentCategoryID = :parentCatId, ScheduleStart = :schedStart, ScheduleEnd = :schedEnd, ScheduleDays = :schedDays @@ -64,6 +85,7 @@ try { name: Name, sortOrder: SortOrder, orderTypes: OrderTypes, + parentCatId: ParentCategoryID, schedStart: { value = ScheduleStart, cfsqltype = "cf_sql_time", null = isNull(ScheduleStart) }, schedEnd: { value = ScheduleEnd, cfsqltype = "cf_sql_time", null = isNull(ScheduleEnd) }, schedDays: { value = ScheduleDays, cfsqltype = "cf_sql_varchar", null = isNull(ScheduleDays) } @@ -77,15 +99,16 @@ try { // Insert new category queryTimed(" INSERT INTO Categories - (BusinessID, Name, SortOrder, OrderTypes, + (BusinessID, Name, SortOrder, OrderTypes, ParentCategoryID, ScheduleStart, ScheduleEnd, ScheduleDays, AddedOn) VALUES - (:bizId, :name, :sortOrder, :orderTypes, :schedStart, :schedEnd, :schedDays, NOW()) + (:bizId, :name, :sortOrder, :orderTypes, :parentCatId, :schedStart, :schedEnd, :schedDays, NOW()) ", { bizId: BusinessID, name: Name, sortOrder: SortOrder, orderTypes: OrderTypes, + parentCatId: ParentCategoryID, schedStart: { value = ScheduleStart, cfsqltype = "cf_sql_time", null = isNull(ScheduleStart) }, schedEnd: { value = ScheduleEnd, cfsqltype = "cf_sql_time", null = isNull(ScheduleEnd) }, schedDays: { value = ScheduleDays, cfsqltype = "cf_sql_varchar", null = isNull(ScheduleDays) } diff --git a/api/menu/saveFromBuilder.cfm b/api/menu/saveFromBuilder.cfm index 59d9b8e..443795f 100644 --- a/api/menu/saveFromBuilder.cfm +++ b/api/menu/saveFromBuilder.cfm @@ -114,6 +114,9 @@ try { // Wrap everything in a transaction for speed and consistency transaction { + // Track JS category id -> DB categoryID mapping for resolving subcategory parents + jsCatIdToDbId = {}; + catSortOrder = 0; for (cat in menu.categories) { categoryID = 0; @@ -126,6 +129,17 @@ try { // Initialize menuId param - use 0 for "no menu" (nullable in DB) categoryMenuId = structKeyExists(cat, "menuId") ? val(cat.menuId) : 0; + // Resolve parentCategoryID: first try dbId, then look up from JS id mapping + parentCategoryID = 0; + if (structKeyExists(cat, "parentCategoryDbId") && val(cat.parentCategoryDbId) > 0) { + parentCategoryID = val(cat.parentCategoryDbId); + } else if (structKeyExists(cat, "parentCategoryId") && len(trim(cat.parentCategoryId)) && cat.parentCategoryId != "0") { + // JS category id - look up from mapping + if (structKeyExists(jsCatIdToDbId, cat.parentCategoryId)) { + parentCategoryID = jsCatIdToDbId[cat.parentCategoryId]; + } + } + if (newSchemaActive) { if (categoryDbId > 0) { categoryID = categoryDbId; @@ -168,23 +182,26 @@ try { UPDATE Categories SET Name = :name, SortOrder = :sortOrder, - MenuID = NULLIF(:menuId, 0) + MenuID = NULLIF(:menuId, 0), + ParentCategoryID = :parentCatId WHERE ID = :categoryID ", { categoryID: categoryID, name: cat.name, sortOrder: catSortOrder, - menuId: categoryMenuId + menuId: categoryMenuId, + parentCatId: parentCategoryID }); } else { queryTimed(" - INSERT INTO Categories (BusinessID, MenuID, Name, SortOrder, AddedOn) - VALUES (:businessID, NULLIF(:menuId, 0), :name, :sortOrder, NOW()) + INSERT INTO Categories (BusinessID, MenuID, Name, SortOrder, ParentCategoryID, AddedOn) + VALUES (:businessID, NULLIF(:menuId, 0), :name, :sortOrder, :parentCatId, NOW()) ", { businessID: businessID, menuId: categoryMenuId, name: cat.name, - sortOrder: catSortOrder + sortOrder: catSortOrder, + parentCatId: parentCategoryID }); result = queryTimed("SELECT LAST_INSERT_ID() as newID"); @@ -192,8 +209,13 @@ try { } } + // Track JS category id -> DB id for subcategory parent resolution + if (structKeyExists(cat, "id") && len(trim(cat.id))) { + jsCatIdToDbId[cat.id] = categoryID; + } + // Debug: log final categoryID for this category - arrayAppend(response.DEBUG, " -> CategoryID resolved to: " & categoryID); + arrayAppend(response.DEBUG, " -> CategoryID resolved to: " & categoryID & " (parentCatID: " & parentCategoryID & ")"); // Process items if (structKeyExists(cat, "items") && isArray(cat.items)) { diff --git a/api/setup/saveWizard.cfm b/api/setup/saveWizard.cfm index 72d8d0e..920e75b 100644 --- a/api/setup/saveWizard.cfm +++ b/api/setup/saveWizard.cfm @@ -528,6 +528,7 @@ try { response.steps.append("Processing " & arrayLen(categories) & " categories..."); + // First pass: create top-level categories (no parentCategoryName) catOrder = 1; for (c = 1; c <= arrayLen(categories); c++) { cat = categories[c]; @@ -536,6 +537,8 @@ try { response.steps.append("Warning: Skipping category with no name at index " & c); continue; } + // Skip subcategories in first pass + if (structKeyExists(cat, "parentCategoryName") && len(trim(cat.parentCategoryName))) continue; // Check if category exists in Categories table for this menu qCat = queryTimed(" @@ -547,7 +550,6 @@ try { categoryID = qCat.ID; response.steps.append("Category exists: " & catName & " (ID: " & categoryID & ")"); } else { - // Create category in Categories table with MenuID queryTimed(" INSERT INTO Categories ( BusinessID, MenuID, Name, SortOrder @@ -570,6 +572,52 @@ try { catOrder++; } + // Second pass: create subcategories (have parentCategoryName) + for (c = 1; c <= arrayLen(categories); c++) { + cat = categories[c]; + catName = structKeyExists(cat, "name") && isSimpleValue(cat.name) ? cat.name : ""; + if (!len(catName)) continue; + parentName = structKeyExists(cat, "parentCategoryName") ? trim(cat.parentCategoryName) : ""; + if (!len(parentName)) continue; + + parentCatID = structKeyExists(categoryMap, parentName) ? categoryMap[parentName] : 0; + if (parentCatID == 0) { + response.steps.append("Warning: Parent category '" & parentName & "' not found for subcategory '" & catName & "'"); + continue; + } + + qCat = queryTimed(" + SELECT ID FROM Categories + WHERE BusinessID = :bizID AND Name = :name AND MenuID = :menuID AND ParentCategoryID = :parentCatID + ", { bizID: businessId, name: catName, menuID: menuID, parentCatID: parentCatID }, { datasource: "payfrit" }); + + if (qCat.recordCount > 0) { + categoryID = qCat.ID; + response.steps.append("Subcategory exists: " & catName & " under " & parentName & " (ID: " & categoryID & ")"); + } else { + queryTimed(" + INSERT INTO Categories ( + BusinessID, MenuID, Name, ParentCategoryID, SortOrder + ) VALUES ( + :bizID, :menuID, :name, :parentCatID, :sortOrder + ) + ", { + bizID: businessId, + menuID: menuID, + name: catName, + parentCatID: parentCatID, + sortOrder: catOrder + }, { datasource: "payfrit" }); + + qNewCat = queryTimed("SELECT LAST_INSERT_ID() as id", {}, { datasource: "payfrit" }); + categoryID = qNewCat.id; + response.steps.append("Created subcategory: " & catName & " under " & parentName & " (ID: " & categoryID & ")"); + } + + categoryMap[catName] = categoryID; + catOrder++; + } + // Create menu items items = structKeyExists(wizardData, "items") ? wizardData.items : []; diff --git a/portal/menu-builder.html b/portal/menu-builder.html index 658e6a4..cc66a4c 100644 --- a/portal/menu-builder.html +++ b/portal/menu-builder.html @@ -1557,14 +1557,16 @@ this.toast('Redo', 'info'); }, - // Add category - addCategory(name = null) { + // Add category (or subcategory if parentCategoryId is provided) + addCategory(name = null, parentCategoryId = null) { this.saveState(); const category = { id: this.generateId(), name: name || `New Category ${this.menu.categories.length + 1}`, description: '', + parentCategoryId: parentCategoryId || 0, + parentCategoryDbId: parentCategoryId ? (this.menu.categories.find(c => c.id === parentCategoryId)?.dbId || 0) : 0, sortOrder: this.menu.categories.length, items: [] }; @@ -1837,6 +1839,24 @@ `` ).join(''); + // Build parent category options (only top-level categories, exclude self and existing subcategories) + const isSubcategory = category.parentCategoryId && category.parentCategoryId !== 0; + const hasSubcategories = this.menu.categories.some(c => + c.parentCategoryId === category.id || c.parentCategoryId === category.dbId + ); + const parentOptions = this.menu.categories + .filter(c => { + // Must be top-level (not a subcategory itself) + if (c.parentCategoryId && c.parentCategoryId !== 0) return false; + // Can't be self + if (c.id === category.id) return false; + return true; + }) + .map(c => { + const isSelected = (category.parentCategoryId === c.id || category.parentCategoryDbId === c.dbId); + return ``; + }).join(''); + document.getElementById('propertiesContent').innerHTML = `
@@ -1848,6 +1868,20 @@
+ ${!hasSubcategories ? ` +
+ + +
+ ` : ` +
+ +

This category has subcategories, so it must remain top-level.

+
+ `} ${this.menus.length > 0 ? `
@@ -1892,9 +1926,11 @@
@@ -2655,6 +2691,27 @@ } }, + // Set parent category (handles both id and dbId tracking) + setParentCategory(categoryId, selectEl) { + this.saveState(); + const category = this.menu.categories.find(c => c.id === categoryId); + if (!category) return; + + const selectedValue = selectEl.value; + if (selectedValue === '0' || !selectedValue) { + category.parentCategoryId = 0; + category.parentCategoryDbId = 0; + } else { + const parentCat = this.menu.categories.find(c => c.id === selectedValue); + if (parentCat) { + category.parentCategoryId = parentCat.id; + category.parentCategoryDbId = parentCat.dbId || 0; + } + } + this.render(); + this.showPropertiesForCategory(category); + }, + // Update item updateItem(categoryId, itemId, field, value) { this.saveState(); @@ -4002,7 +4059,64 @@ }).join(''); }, - // Render menu structure + // Render a single item card (reused by categories and subcategories) + renderItemCard(item, category) { + const itemExpanded = this.expandedItemId === item.id; + const hasModifiers = item.modifiers && item.modifiers.length > 0; + return ` +
+ ${hasModifiers ? ` +
+ + + +
+ ` : ''} +
+ + + + + +
+
+ ${item.imageUrl ? `` : '🍽️'} + ${item.photoTaskId ? ` +
+ + + + + +
+ ` : ''} +
+
+
${this.escapeHtml(item.name)}
+
+ $${(item.price || 0).toFixed(2)} + ${hasModifiers ? `${item.modifiers.length} modifiers` : ''} +
+
+
+ + +
+
+ ${itemExpanded ? this.renderModifiers(item.modifiers, item.id, 1) : ''} + `; + }, + // Render menu structure render() { const container = document.getElementById('menuStructure'); @@ -4020,8 +4134,19 @@ return; } - container.innerHTML = this.menu.categories.map(category => { + // Separate top-level categories and subcategories + const topLevelCategories = this.menu.categories.filter(c => !c.parentCategoryId || c.parentCategoryId === 0); + const getSubcategories = (parentCat) => this.menu.categories.filter(c => + c.parentCategoryId === parentCat.id || (parentCat.dbId && c.parentCategoryDbId === parentCat.dbId) + ); + + container.innerHTML = topLevelCategories.map(category => { const isExpanded = this.expandedCategoryId === category.id; + const subcategories = getSubcategories(category); + const hasSubcats = subcategories.length > 0; + const totalItems = hasSubcats + ? subcategories.reduce((sum, sc) => sum + sc.items.length, 0) + category.items.length + : category.items.length; return `
@@ -4040,9 +4165,16 @@
${this.escapeHtml(category.name)}
-
${category.items.length} items
+
${totalItems} items${hasSubcats ? `, ${subcategories.length} subcategories` : ''}
+ ${hasSubcats || category.items.length === 0 ? ` + + ` : ''}
- ${category.items.length === 0 ? ` -
Drag items here or click + to add
-` : category.items.map(item => { - const itemExpanded = this.expandedItemId === item.id; - const hasModifiers = item.modifiers && item.modifiers.length > 0; + ${hasSubcats ? subcategories.map(subcat => { + const subExpanded = this.expandedCategoryId === subcat.id; return ` -
- ${hasModifiers ? ` -
- - - -
- ` : ''} -
- - - - - -
-
- ${item.imageUrl ? `` : '🍽️'} - ${item.photoTaskId ? ` -
- - - - +
+
+
+ + + +
+
+ + + + + +
+
+
${this.escapeHtml(subcat.name)}
+
${subcat.items.length} items
+
+
+
- ` : ''} -
-
-
${this.escapeHtml(item.name)}
-
- $${(item.price || 0).toFixed(2)} - ${hasModifiers ? `${item.modifiers.length} modifiers` : ''} + +
-
- - +
+ ${subcat.items.length === 0 ? ` +
Drag items here or click + to add
+ ` : subcat.items.map(item => this.renderItemCard(item, subcat)).join('')}
-
- ${itemExpanded ? this.renderModifiers(item.modifiers, item.id, 1) : ''} - `}).join('')} +
`; + }).join('') : ''} + ${!hasSubcats && category.items.length === 0 ? ` +
Drag items here or click + to add
+` : category.items.map(item => this.renderItemCard(item, category)).join('')}
`}).join('');