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:
parent
e02e124610
commit
3ccc82c9f2
6 changed files with 303 additions and 71 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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 : [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
|
|
|
|||
Reference in a new issue