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
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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 : [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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('');
|
||||||
|
|
|
||||||
Reference in a new issue