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
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);

View file

@ -145,6 +145,7 @@
Name,
SortOrder,
OrderTypes,
ParentCategoryID,
ScheduleStart,
ScheduleEnd,
ScheduleDays,
@ -323,17 +324,27 @@
</cfif>
</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")>
<!--- 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">
<cfif structKeyExists(categoriesWithItems, qCategories.ID)>
<!--- Add category as a virtual parent item --->
<!--- Include top-level categories even without direct items (subcats may have items) --->
<cfset arrayAppend(rows, {
"ItemID": qCategories.ID,
"CategoryID": qCategories.ID,
"Name": qCategories.Name,
"Description": "",
"ParentItemID": 0,
"ParentCategoryID": isNull(qCategories.ParentCategoryID) ? 0 : val(qCategories.ParentCategoryID),
"Price": 0,
"IsActive": 1,
"IsCheckedByDefault": 0,

View file

@ -14,6 +14,7 @@
* "Name": "Breakfast",
* "SortOrder": 1,
* "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
* "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) }

View file

@ -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)) {

View file

@ -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 : [];

View file

@ -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 @@
`<option value="${m.MenuID}" ${category.menuId === m.MenuID ? 'selected' : ''}>${this.escapeHtml(m.MenuName)}</option>`
).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 = `
<div class="property-group">
<label>Category Name</label>
@ -1848,6 +1868,20 @@
<textarea id="propCatDesc" rows="3"
onchange="MenuBuilder.updateCategory('${category.id}', 'description', this.value)">${this.escapeHtml(category.description || '')}</textarea>
</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 ? `
<div class="property-group">
<label>Assign to Menu</label>
@ -1892,9 +1926,11 @@
<div class="property-group">
<label>Category</label>
<select onchange="MenuBuilder.moveItemToCategory('${item.id}', this.value)">
${this.menu.categories.map(c =>
`<option value="${c.id}" ${c.id === category.id ? 'selected' : ''}>${this.escapeHtml(c.name)}</option>`
).join('')}
${this.menu.categories.map(c => {
const isSubcat = c.parentCategoryId && c.parentCategoryId !== 0;
const prefix = isSubcat ? '\u00A0\u00A0\u2514 ' : '';
return `<option value="${c.id}" ${c.id === category.id ? 'selected' : ''}>${prefix}${this.escapeHtml(c.name)}</option>`;
}).join('')}
</select>
</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
updateItem(categoryId, itemId, field, value) {
this.saveState();
@ -4002,63 +4059,8 @@
}).join('');
},
// Render menu structure
// Render menu structure
render() {
const container = document.getElementById('menuStructure');
if (this.menu.categories.length === 0) {
container.innerHTML = `
<div class="empty-canvas" id="emptyCanvas">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M3 6h18M3 12h18M3 18h18"/>
</svg>
<h3>Start Building Your Menu</h3>
<p>Drag categories and items from the left panel, or click the buttons above to add them.</p>
</div>
`;
return;
}
container.innerHTML = this.menu.categories.map(category => {
const isExpanded = this.expandedCategoryId === category.id;
return `
<div class="category-card" data-category-id="${category.id}" draggable="true"
onclick="MenuBuilder.selectElement(this)">
<div class="category-header">
<div class="category-toggle ${isExpanded ? 'expanded' : ''}" onclick="event.stopPropagation(); MenuBuilder.toggleCategory('${category.id}')">
<svg width="16" height="16" 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="16" height="16" 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="category-info" onclick="event.stopPropagation(); MenuBuilder.toggleCategory('${category.id}')" style="cursor: pointer;">
<div class="category-name">${this.escapeHtml(category.name)}</div>
<div class="category-count">${category.items.length} items</div>
</div>
<div class="category-actions">
<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">
<path d="M12 5v14M5 12h14"/>
</svg>
</button>
<button class="danger" onclick="event.stopPropagation(); MenuBuilder.deleteCategory('${category.id}')" title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
</svg>
</button>
</div>
</div>
<div class="items-list ${isExpanded ? '' : 'collapsed'}">
${category.items.length === 0 ? `
<div class="item-drop-zone">Drag items here or click + to add</div>
` : category.items.map(item => {
// 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 `
@ -4112,7 +4114,125 @@
</div>
</div>
${itemExpanded ? this.renderModifiers(item.modifiers, item.id, 1) : ''}
`}).join('')}
`;
},
// Render menu structure
render() {
const container = document.getElementById('menuStructure');
if (this.menu.categories.length === 0) {
container.innerHTML = `
<div class="empty-canvas" id="emptyCanvas">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M3 6h18M3 12h18M3 18h18"/>
</svg>
<h3>Start Building Your Menu</h3>
<p>Drag categories and items from the left panel, or click the buttons above to add them.</p>
</div>
`;
return;
}
// 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 `
<div class="category-card" data-category-id="${category.id}" draggable="true"
onclick="MenuBuilder.selectElement(this)">
<div class="category-header">
<div class="category-toggle ${isExpanded ? 'expanded' : ''}" onclick="event.stopPropagation(); MenuBuilder.toggleCategory('${category.id}')">
<svg width="16" height="16" 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="16" height="16" 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="category-info" onclick="event.stopPropagation(); MenuBuilder.toggleCategory('${category.id}')" style="cursor: pointer;">
<div class="category-name">${this.escapeHtml(category.name)}</div>
<div class="category-count">${totalItems} items${hasSubcats ? `, ${subcategories.length} subcategories` : ''}</div>
</div>
<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">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
</svg>
</button>
<button class="danger" onclick="event.stopPropagation(); MenuBuilder.deleteCategory('${category.id}')" title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
</svg>
</button>
</div>
</div>
<div class="items-list ${isExpanded ? '' : 'collapsed'}">
${hasSubcats ? subcategories.map(subcat => {
const subExpanded = this.expandedCategoryId === subcat.id;
return `
<div class="category-card subcategory-card" data-category-id="${subcat.id}" draggable="true"
onclick="event.stopPropagation(); MenuBuilder.selectElement(this)" style="margin: 8px 0 8px 20px; border-color: var(--gray-300);">
<div class="category-header" style="padding: 8px 12px;">
<div class="category-toggle ${subExpanded ? 'expanded' : ''}" onclick="event.stopPropagation(); MenuBuilder.toggleCategory('${subcat.id}')">
<svg width="14" height="14" 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="category-info" onclick="event.stopPropagation(); MenuBuilder.toggleCategory('${subcat.id}')" style="cursor: pointer;">
<div class="category-name" style="font-size: 14px;">${this.escapeHtml(subcat.name)}</div>
<div class="category-count">${subcat.items.length} items</div>
</div>
<div class="category-actions">
<button onclick="event.stopPropagation(); MenuBuilder.addItem('${subcat.id}')" title="Add Item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
</svg>
</button>
<button class="danger" onclick="event.stopPropagation(); MenuBuilder.deleteCategory('${subcat.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 class="items-list ${subExpanded ? '' : 'collapsed'}">
${subcat.items.length === 0 ? `
<div class="item-drop-zone">Drag items here or click + to add</div>
` : subcat.items.map(item => this.renderItemCard(item, subcat)).join('')}
</div>
</div>`;
}).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>
`}).join('');