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 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-02-28 21:29:40 -08:00
parent e02e124610
commit 3ccc82c9f2
6 changed files with 303 additions and 71 deletions

View file

@ -160,6 +160,7 @@ try {
SELECT SELECT
ID, ID,
Name, Name,
ParentCategoryID,
SortOrder as SortOrder, SortOrder as SortOrder,
MenuID MenuID
FROM Categories FROM Categories
@ -434,13 +435,20 @@ try {
"items": catItems "items": catItems
}; };
// Include MenuID if available (legacy schema with Categories table) // Include MenuID and ParentCategoryID if available (legacy schema with Categories table)
if (hasCategoriesData) { if (hasCategoriesData) {
try { try {
catStruct["menuId"] = isNull(qCategories.MenuID[i]) ? 0 : val(qCategories.MenuID[i]); catStruct["menuId"] = isNull(qCategories.MenuID[i]) ? 0 : val(qCategories.MenuID[i]);
} catch (any e) { } catch (any e) {
catStruct["menuId"] = 0; 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); arrayAppend(categories, catStruct);

View file

@ -145,6 +145,7 @@
Name, Name,
SortOrder, SortOrder,
OrderTypes, OrderTypes,
ParentCategoryID,
ScheduleStart, ScheduleStart,
ScheduleEnd, ScheduleEnd,
ScheduleDays, ScheduleDays,
@ -323,17 +324,27 @@
</cfif> </cfif>
</cfloop> </cfloop>
<!--- For unified schema with Categories table, add category headers (only if they have items) ---> <!--- For unified schema with Categories table, add category headers --->
<!--- Include categories that have items, plus parent categories whose subcategories have items --->
<cfif newSchemaActive AND isDefined("qCategories")> <cfif newSchemaActive AND isDefined("qCategories")>
<!--- Second pass: mark parent categories of subcategories that have items --->
<cfloop query="qCategories">
<cfif val(qCategories.ParentCategoryID) GT 0
AND structKeyExists(categoriesWithItems, qCategories.ID)>
<cfset categoriesWithItems[qCategories.ParentCategoryID] = true>
</cfif>
</cfloop>
<cfloop query="qCategories"> <cfloop query="qCategories">
<cfif structKeyExists(categoriesWithItems, qCategories.ID)> <cfif structKeyExists(categoriesWithItems, qCategories.ID)>
<!--- Add category as a virtual parent item ---> <!--- Add category as a virtual parent item --->
<!--- Include top-level categories even without direct items (subcats may have items) --->
<cfset arrayAppend(rows, { <cfset arrayAppend(rows, {
"ItemID": qCategories.ID, "ItemID": qCategories.ID,
"CategoryID": qCategories.ID, "CategoryID": qCategories.ID,
"Name": qCategories.Name, "Name": qCategories.Name,
"Description": "", "Description": "",
"ParentItemID": 0, "ParentItemID": 0,
"ParentCategoryID": isNull(qCategories.ParentCategoryID) ? 0 : val(qCategories.ParentCategoryID),
"Price": 0, "Price": 0,
"IsActive": 1, "IsActive": 1,
"IsCheckedByDefault": 0, "IsCheckedByDefault": 0,

View file

@ -14,6 +14,7 @@
* "Name": "Breakfast", * "Name": "Breakfast",
* "SortOrder": 1, * "SortOrder": 1,
* "OrderTypes": "1,2,3", // 1=Dine-In, 2=Takeaway, 3=Delivery * "OrderTypes": "1,2,3", // 1=Dine-In, 2=Takeaway, 3=Delivery
* "ParentCategoryID": 0, // 0 = top-level, >0 = subcategory (max 2 levels)
* "ScheduleStart": "06:00:00", // Optional, null = always available * "ScheduleStart": "06:00:00", // Optional, null = always available
* "ScheduleEnd": "11:00:00", // Optional * "ScheduleEnd": "11:00:00", // Optional
* "ScheduleDays": "2,3,4,5,6" // Optional, 1=Sun..7=Sat, null = all days * "ScheduleDays": "2,3,4,5,6" // Optional, 1=Sun..7=Sat, null = all days
@ -47,6 +48,25 @@ try {
? trim(data.ScheduleEnd) : javaCast("null", ""); ? trim(data.ScheduleEnd) : javaCast("null", "");
ScheduleDays = structKeyExists(data, "ScheduleDays") && len(trim(data.ScheduleDays)) ScheduleDays = structKeyExists(data, "ScheduleDays") && len(trim(data.ScheduleDays))
? trim(data.ScheduleDays) : javaCast("null", ""); ? 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) { if (CategoryID > 0) {
// Update existing category // Update existing category
@ -55,6 +75,7 @@ try {
Name = :name, Name = :name,
SortOrder = :sortOrder, SortOrder = :sortOrder,
OrderTypes = :orderTypes, OrderTypes = :orderTypes,
ParentCategoryID = :parentCatId,
ScheduleStart = :schedStart, ScheduleStart = :schedStart,
ScheduleEnd = :schedEnd, ScheduleEnd = :schedEnd,
ScheduleDays = :schedDays ScheduleDays = :schedDays
@ -64,6 +85,7 @@ try {
name: Name, name: Name,
sortOrder: SortOrder, sortOrder: SortOrder,
orderTypes: OrderTypes, orderTypes: OrderTypes,
parentCatId: ParentCategoryID,
schedStart: { value = ScheduleStart, cfsqltype = "cf_sql_time", null = isNull(ScheduleStart) }, schedStart: { value = ScheduleStart, cfsqltype = "cf_sql_time", null = isNull(ScheduleStart) },
schedEnd: { value = ScheduleEnd, cfsqltype = "cf_sql_time", null = isNull(ScheduleEnd) }, schedEnd: { value = ScheduleEnd, cfsqltype = "cf_sql_time", null = isNull(ScheduleEnd) },
schedDays: { value = ScheduleDays, cfsqltype = "cf_sql_varchar", null = isNull(ScheduleDays) } schedDays: { value = ScheduleDays, cfsqltype = "cf_sql_varchar", null = isNull(ScheduleDays) }
@ -77,15 +99,16 @@ try {
// Insert new category // Insert new category
queryTimed(" queryTimed("
INSERT INTO Categories INSERT INTO Categories
(BusinessID, Name, SortOrder, OrderTypes, (BusinessID, Name, SortOrder, OrderTypes, ParentCategoryID,
ScheduleStart, ScheduleEnd, ScheduleDays, AddedOn) ScheduleStart, ScheduleEnd, ScheduleDays, AddedOn)
VALUES VALUES
(:bizId, :name, :sortOrder, :orderTypes, :schedStart, :schedEnd, :schedDays, NOW()) (:bizId, :name, :sortOrder, :orderTypes, :parentCatId, :schedStart, :schedEnd, :schedDays, NOW())
", { ", {
bizId: BusinessID, bizId: BusinessID,
name: Name, name: Name,
sortOrder: SortOrder, sortOrder: SortOrder,
orderTypes: OrderTypes, orderTypes: OrderTypes,
parentCatId: ParentCategoryID,
schedStart: { value = ScheduleStart, cfsqltype = "cf_sql_time", null = isNull(ScheduleStart) }, schedStart: { value = ScheduleStart, cfsqltype = "cf_sql_time", null = isNull(ScheduleStart) },
schedEnd: { value = ScheduleEnd, cfsqltype = "cf_sql_time", null = isNull(ScheduleEnd) }, schedEnd: { value = ScheduleEnd, cfsqltype = "cf_sql_time", null = isNull(ScheduleEnd) },
schedDays: { value = ScheduleDays, cfsqltype = "cf_sql_varchar", null = isNull(ScheduleDays) } schedDays: { value = ScheduleDays, cfsqltype = "cf_sql_varchar", null = isNull(ScheduleDays) }

View file

@ -114,6 +114,9 @@ try {
// Wrap everything in a transaction for speed and consistency // Wrap everything in a transaction for speed and consistency
transaction { transaction {
// Track JS category id -> DB categoryID mapping for resolving subcategory parents
jsCatIdToDbId = {};
catSortOrder = 0; catSortOrder = 0;
for (cat in menu.categories) { for (cat in menu.categories) {
categoryID = 0; categoryID = 0;
@ -126,6 +129,17 @@ try {
// Initialize menuId param - use 0 for "no menu" (nullable in DB) // Initialize menuId param - use 0 for "no menu" (nullable in DB)
categoryMenuId = structKeyExists(cat, "menuId") ? val(cat.menuId) : 0; 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 (newSchemaActive) {
if (categoryDbId > 0) { if (categoryDbId > 0) {
categoryID = categoryDbId; categoryID = categoryDbId;
@ -168,23 +182,26 @@ try {
UPDATE Categories UPDATE Categories
SET Name = :name, SET Name = :name,
SortOrder = :sortOrder, SortOrder = :sortOrder,
MenuID = NULLIF(:menuId, 0) MenuID = NULLIF(:menuId, 0),
ParentCategoryID = :parentCatId
WHERE ID = :categoryID WHERE ID = :categoryID
", { ", {
categoryID: categoryID, categoryID: categoryID,
name: cat.name, name: cat.name,
sortOrder: catSortOrder, sortOrder: catSortOrder,
menuId: categoryMenuId menuId: categoryMenuId,
parentCatId: parentCategoryID
}); });
} else { } else {
queryTimed(" queryTimed("
INSERT INTO Categories (BusinessID, MenuID, Name, SortOrder, AddedOn) INSERT INTO Categories (BusinessID, MenuID, Name, SortOrder, ParentCategoryID, AddedOn)
VALUES (:businessID, NULLIF(:menuId, 0), :name, :sortOrder, NOW()) VALUES (:businessID, NULLIF(:menuId, 0), :name, :sortOrder, :parentCatId, NOW())
", { ", {
businessID: businessID, businessID: businessID,
menuId: categoryMenuId, menuId: categoryMenuId,
name: cat.name, name: cat.name,
sortOrder: catSortOrder sortOrder: catSortOrder,
parentCatId: parentCategoryID
}); });
result = queryTimed("SELECT LAST_INSERT_ID() as newID"); 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 // 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 // Process items
if (structKeyExists(cat, "items") && isArray(cat.items)) { if (structKeyExists(cat, "items") && isArray(cat.items)) {

View file

@ -528,6 +528,7 @@ try {
response.steps.append("Processing " & arrayLen(categories) & " categories..."); response.steps.append("Processing " & arrayLen(categories) & " categories...");
// First pass: create top-level categories (no parentCategoryName)
catOrder = 1; catOrder = 1;
for (c = 1; c <= arrayLen(categories); c++) { for (c = 1; c <= arrayLen(categories); c++) {
cat = categories[c]; cat = categories[c];
@ -536,6 +537,8 @@ try {
response.steps.append("Warning: Skipping category with no name at index " & c); response.steps.append("Warning: Skipping category with no name at index " & c);
continue; 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 // Check if category exists in Categories table for this menu
qCat = queryTimed(" qCat = queryTimed("
@ -547,7 +550,6 @@ try {
categoryID = qCat.ID; categoryID = qCat.ID;
response.steps.append("Category exists: " & catName & " (ID: " & categoryID & ")"); response.steps.append("Category exists: " & catName & " (ID: " & categoryID & ")");
} else { } else {
// Create category in Categories table with MenuID
queryTimed(" queryTimed("
INSERT INTO Categories ( INSERT INTO Categories (
BusinessID, MenuID, Name, SortOrder BusinessID, MenuID, Name, SortOrder
@ -570,6 +572,52 @@ try {
catOrder++; 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 // Create menu items
items = structKeyExists(wizardData, "items") ? wizardData.items : []; items = structKeyExists(wizardData, "items") ? wizardData.items : [];

View file

@ -1557,14 +1557,16 @@
this.toast('Redo', 'info'); this.toast('Redo', 'info');
}, },
// Add category // Add category (or subcategory if parentCategoryId is provided)
addCategory(name = null) { addCategory(name = null, parentCategoryId = null) {
this.saveState(); this.saveState();
const category = { const category = {
id: this.generateId(), id: this.generateId(),
name: name || `New Category ${this.menu.categories.length + 1}`, name: name || `New Category ${this.menu.categories.length + 1}`,
description: '', description: '',
parentCategoryId: parentCategoryId || 0,
parentCategoryDbId: parentCategoryId ? (this.menu.categories.find(c => c.id === parentCategoryId)?.dbId || 0) : 0,
sortOrder: this.menu.categories.length, sortOrder: this.menu.categories.length,
items: [] items: []
}; };
@ -1837,6 +1839,24 @@
`<option value="${m.MenuID}" ${category.menuId === m.MenuID ? 'selected' : ''}>${this.escapeHtml(m.MenuName)}</option>` `<option value="${m.MenuID}" ${category.menuId === m.MenuID ? 'selected' : ''}>${this.escapeHtml(m.MenuName)}</option>`
).join(''); ).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 `<option value="${c.id}" data-dbid="${c.dbId || 0}" ${isSelected ? 'selected' : ''}>${this.escapeHtml(c.name)}</option>`;
}).join('');
document.getElementById('propertiesContent').innerHTML = ` document.getElementById('propertiesContent').innerHTML = `
<div class="property-group"> <div class="property-group">
<label>Category Name</label> <label>Category Name</label>
@ -1848,6 +1868,20 @@
<textarea id="propCatDesc" rows="3" <textarea id="propCatDesc" rows="3"
onchange="MenuBuilder.updateCategory('${category.id}', 'description', this.value)">${this.escapeHtml(category.description || '')}</textarea> onchange="MenuBuilder.updateCategory('${category.id}', 'description', this.value)">${this.escapeHtml(category.description || '')}</textarea>
</div> </div>
${!hasSubcategories ? `
<div class="property-group">
<label>Parent Category</label>
<select onchange="MenuBuilder.setParentCategory('${category.id}', this)">
<option value="0" ${!category.parentCategoryId || category.parentCategoryId === 0 ? 'selected' : ''}>None (top-level)</option>
${parentOptions}
</select>
</div>
` : `
<div class="property-group">
<label>Parent Category</label>
<p style="color: var(--gray-500); font-size: 13px; margin: 4px 0 0;">This category has subcategories, so it must remain top-level.</p>
</div>
`}
${this.menus.length > 0 ? ` ${this.menus.length > 0 ? `
<div class="property-group"> <div class="property-group">
<label>Assign to Menu</label> <label>Assign to Menu</label>
@ -1892,9 +1926,11 @@
<div class="property-group"> <div class="property-group">
<label>Category</label> <label>Category</label>
<select onchange="MenuBuilder.moveItemToCategory('${item.id}', this.value)"> <select onchange="MenuBuilder.moveItemToCategory('${item.id}', this.value)">
${this.menu.categories.map(c => ${this.menu.categories.map(c => {
`<option value="${c.id}" ${c.id === category.id ? 'selected' : ''}>${this.escapeHtml(c.name)}</option>` const isSubcat = c.parentCategoryId && c.parentCategoryId !== 0;
).join('')} const prefix = isSubcat ? '\u00A0\u00A0\u2514 ' : '';
return `<option value="${c.id}" ${c.id === category.id ? 'selected' : ''}>${prefix}${this.escapeHtml(c.name)}</option>`;
}).join('')}
</select> </select>
</div> </div>
</div> </div>
@ -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 // Update item
updateItem(categoryId, itemId, field, value) { updateItem(categoryId, itemId, field, value) {
this.saveState(); this.saveState();
@ -4002,7 +4059,64 @@
}).join(''); }).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 `
<div class="item-card ${itemExpanded ? 'expanded' : ''}" data-item-id="${item.id}" draggable="true"
onclick="event.stopPropagation(); MenuBuilder.selectElement(this)">
${hasModifiers ? `
<div class="item-toggle ${itemExpanded ? 'expanded' : ''}" onclick="event.stopPropagation(); MenuBuilder.toggleItem('${item.id}')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 18l6-6-6-6"/>
</svg>
</div>
` : ''}
<div class="drag-handle">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
</svg>
</div>
<div class="item-image">
${item.imageUrl ? `<img src="${this.getImageUrls(item.imageUrl).thumb}" alt="" onerror="this.onerror=null; this.src='${item.imageUrl}'">` : '🍽️'}
${item.photoTaskId ? `
<div class="photo-badge ${item.imageUrl ? '' : 'missing'}" title="${item.imageUrl ? 'Has photo' : 'Photo task pending'}">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</svg>
</div>
` : ''}
</div>
<div class="item-info" onclick="event.stopPropagation(); ${hasModifiers ? `MenuBuilder.toggleItem('${item.id}')` : `MenuBuilder.selectItem('${item.id}')`}" style="cursor: pointer;">
<div class="item-name">${this.escapeHtml(item.name)}</div>
<div class="item-meta">
<span class="item-price">$${(item.price || 0).toFixed(2)}</span>
${hasModifiers ? `<span>${item.modifiers.length} modifiers</span>` : ''}
</div>
</div>
<div class="item-actions">
<button onclick="event.stopPropagation(); MenuBuilder.cloneItem('${category.id}', '${item.id}')" title="Clone">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
</svg>
</button>
<button class="danger" onclick="event.stopPropagation(); MenuBuilder.deleteItem('${category.id}', '${item.id}')" title="Delete">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
</div>
${itemExpanded ? this.renderModifiers(item.modifiers, item.id, 1) : ''}
`;
},
// Render menu structure // Render menu structure
render() { render() {
const container = document.getElementById('menuStructure'); const container = document.getElementById('menuStructure');
@ -4020,8 +4134,19 @@
return; 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 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 ` return `
<div class="category-card" data-category-id="${category.id}" draggable="true" <div class="category-card" data-category-id="${category.id}" draggable="true"
onclick="MenuBuilder.selectElement(this)"> onclick="MenuBuilder.selectElement(this)">
@ -4040,9 +4165,16 @@
</div> </div>
<div class="category-info" onclick="event.stopPropagation(); MenuBuilder.toggleCategory('${category.id}')" style="cursor: pointer;"> <div class="category-info" onclick="event.stopPropagation(); MenuBuilder.toggleCategory('${category.id}')" style="cursor: pointer;">
<div class="category-name">${this.escapeHtml(category.name)}</div> <div class="category-name">${this.escapeHtml(category.name)}</div>
<div class="category-count">${category.items.length} items</div> <div class="category-count">${totalItems} items${hasSubcats ? `, ${subcategories.length} subcategories` : ''}</div>
</div> </div>
<div class="category-actions"> <div class="category-actions">
${hasSubcats || category.items.length === 0 ? `
<button onclick="event.stopPropagation(); MenuBuilder.addCategory(null, '${category.id}')" title="Add Subcategory">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
</button>
` : ''}
<button onclick="event.stopPropagation(); MenuBuilder.addItem('${category.id}')" title="Add Item"> <button onclick="event.stopPropagation(); MenuBuilder.addItem('${category.id}')" title="Add Item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/> <path d="M12 5v14M5 12h14"/>
@ -4056,63 +4188,51 @@
</div> </div>
</div> </div>
<div class="items-list ${isExpanded ? '' : 'collapsed'}"> <div class="items-list ${isExpanded ? '' : 'collapsed'}">
${category.items.length === 0 ? ` ${hasSubcats ? subcategories.map(subcat => {
<div class="item-drop-zone">Drag items here or click + to add</div> const subExpanded = this.expandedCategoryId === subcat.id;
` : category.items.map(item => {
const itemExpanded = this.expandedItemId === item.id;
const hasModifiers = item.modifiers && item.modifiers.length > 0;
return ` return `
<div class="item-card ${itemExpanded ? 'expanded' : ''}" data-item-id="${item.id}" draggable="true" <div class="category-card subcategory-card" data-category-id="${subcat.id}" draggable="true"
onclick="event.stopPropagation(); MenuBuilder.selectElement(this)"> onclick="event.stopPropagation(); MenuBuilder.selectElement(this)" style="margin: 8px 0 8px 20px; border-color: var(--gray-300);">
${hasModifiers ? ` <div class="category-header" style="padding: 8px 12px;">
<div class="item-toggle ${itemExpanded ? 'expanded' : ''}" onclick="event.stopPropagation(); MenuBuilder.toggleItem('${item.id}')"> <div class="category-toggle ${subExpanded ? 'expanded' : ''}" onclick="event.stopPropagation(); MenuBuilder.toggleCategory('${subcat.id}')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 18l6-6-6-6"/> <path d="M9 18l6-6-6-6"/>
</svg> </svg>
</div> </div>
` : ''} <div class="drag-handle">
<div class="drag-handle"> <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"> <circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/> <circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/> <circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/> </svg>
</svg> </div>
</div> <div class="category-info" onclick="event.stopPropagation(); MenuBuilder.toggleCategory('${subcat.id}')" style="cursor: pointer;">
<div class="item-image"> <div class="category-name" style="font-size: 14px;">${this.escapeHtml(subcat.name)}</div>
${item.imageUrl ? `<img src="${this.getImageUrls(item.imageUrl).thumb}" alt="" onerror="this.onerror=null; this.src='${item.imageUrl}'">` : '🍽️'} <div class="category-count">${subcat.items.length} items</div>
${item.photoTaskId ? ` </div>
<div class="photo-badge ${item.imageUrl ? '' : 'missing'}" title="${item.imageUrl ? 'Has photo' : 'Photo task pending'}"> <div class="category-actions">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <button onclick="event.stopPropagation(); MenuBuilder.addItem('${subcat.id}')" title="Add Item">
<rect x="3" y="3" width="18" height="18" rx="2"/> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="8.5" cy="8.5" r="1.5"/> <path d="M12 5v14M5 12h14"/>
<path d="M21 15l-5-5L5 21"/>
</svg> </svg>
</div> </button>
` : ''} <button class="danger" onclick="event.stopPropagation(); MenuBuilder.deleteCategory('${subcat.id}')" title="Delete">
</div> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<div class="item-info" onclick="event.stopPropagation(); ${hasModifiers ? `MenuBuilder.toggleItem('${item.id}')` : `MenuBuilder.selectItem('${item.id}')`}" style="cursor: pointer;"> <path d="M18 6L6 18M6 6l12 12"/>
<div class="item-name">${this.escapeHtml(item.name)}</div> </svg>
<div class="item-meta"> </button>
<span class="item-price">$${(item.price || 0).toFixed(2)}</span>
${hasModifiers ? `<span>${item.modifiers.length} modifiers</span>` : ''}
</div> </div>
</div> </div>
<div class="item-actions"> <div class="items-list ${subExpanded ? '' : 'collapsed'}">
<button onclick="event.stopPropagation(); MenuBuilder.cloneItem('${category.id}', '${item.id}')" title="Clone"> ${subcat.items.length === 0 ? `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <div class="item-drop-zone">Drag items here or click + to add</div>
<rect x="9" y="9" width="13" height="13" rx="2"/> ` : subcat.items.map(item => this.renderItemCard(item, subcat)).join('')}
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
</svg>
</button>
<button class="danger" onclick="event.stopPropagation(); MenuBuilder.deleteItem('${category.id}', '${item.id}')" title="Delete">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div> </div>
</div> </div>`;
${itemExpanded ? this.renderModifiers(item.modifiers, item.id, 1) : ''} }).join('') : ''}
`}).join('')} ${!hasSubcats && category.items.length === 0 ? `
<div class="item-drop-zone">Drag items here or click + to add</div>
` : category.items.map(item => this.renderItemCard(item, category)).join('')}
</div> </div>
</div> </div>
`}).join(''); `}).join('');