Add IsInvertedGroup support for modifier groups

When enabled on a modifier group, KDS and cart only show removed
defaults (e.g., "NO Mustard") instead of listing all selected items.
Useful for groups where all options are checked by default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-03-08 18:14:19 -07:00
parent 49d724f9b2
commit 717d60d6e6
6 changed files with 92 additions and 27 deletions

View file

@ -26,6 +26,7 @@ function buildOptionsTree(allOptions, parentId) {
"sortOrder": allOptions.SortOrder[i], "sortOrder": allOptions.SortOrder[i],
"requiresSelection": isNull(allOptions.RequiresSelection[i]) ? false : (allOptions.RequiresSelection[i] == 1), "requiresSelection": isNull(allOptions.RequiresSelection[i]) ? false : (allOptions.RequiresSelection[i] == 1),
"maxSelections": isNull(allOptions.MaxSelections[i]) ? 0 : allOptions.MaxSelections[i], "maxSelections": isNull(allOptions.MaxSelections[i]) ? 0 : allOptions.MaxSelections[i],
"isInverted": structKeyExists(allOptions, "IsInvertedGroup") && !isNull(allOptions.IsInvertedGroup[i]) ? (allOptions.IsInvertedGroup[i] == 1) : false,
"options": children "options": children
}); });
} }
@ -195,7 +196,8 @@ try {
m.IsCheckedByDefault as IsDefault, m.IsCheckedByDefault as IsDefault,
m.SortOrder, m.SortOrder,
m.RequiresChildSelection as RequiresSelection, m.RequiresChildSelection as RequiresSelection,
m.MaxNumSelectionReq as MaxSelections m.MaxNumSelectionReq as MaxSelections,
m.IsInvertedGroup
FROM Items m FROM Items m
WHERE m.BusinessID = :businessID WHERE m.BusinessID = :businessID
AND m.IsActive = 1 AND m.IsActive = 1
@ -251,7 +253,8 @@ try {
m.IsCheckedByDefault as IsDefault, m.IsCheckedByDefault as IsDefault,
m.SortOrder, m.SortOrder,
m.RequiresChildSelection as RequiresSelection, m.RequiresChildSelection as RequiresSelection,
m.MaxNumSelectionReq as MaxSelections m.MaxNumSelectionReq as MaxSelections,
m.IsInvertedGroup
FROM Items m FROM Items m
WHERE m.BusinessID = :businessID WHERE m.BusinessID = :businessID
AND m.IsActive = 1 AND m.IsActive = 1
@ -289,7 +292,8 @@ try {
t.IsCheckedByDefault as IsDefault, t.IsCheckedByDefault as IsDefault,
t.SortOrder, t.SortOrder,
t.RequiresChildSelection as RequiresSelection, t.RequiresChildSelection as RequiresSelection,
t.MaxNumSelectionReq as MaxSelections t.MaxNumSelectionReq as MaxSelections,
t.IsInvertedGroup
FROM Items t FROM Items t
WHERE t.BusinessID = :businessID WHERE t.BusinessID = :businessID
AND (t.CategoryID = 0 OR t.CategoryID IS NULL) AND (t.CategoryID = 0 OR t.CategoryID IS NULL)
@ -304,7 +308,7 @@ try {
arrayAppend(templateIds, qTemplates.ItemID[i]); arrayAppend(templateIds, qTemplates.ItemID[i]);
} }
qTemplateChildren = queryNew("ItemID,ParentItemID,Name,Price,IsDefault,SortOrder,RequiresSelection,MaxSelections"); qTemplateChildren = queryNew("ItemID,ParentItemID,Name,Price,IsDefault,SortOrder,RequiresSelection,MaxSelections,IsInvertedGroup");
if (arrayLen(templateIds) > 0) { if (arrayLen(templateIds) > 0) {
qTemplateChildren = queryTimed(" qTemplateChildren = queryTimed("
SELECT SELECT
@ -315,7 +319,8 @@ try {
c.IsCheckedByDefault as IsDefault, c.IsCheckedByDefault as IsDefault,
c.SortOrder, c.SortOrder,
c.RequiresChildSelection as RequiresSelection, c.RequiresChildSelection as RequiresSelection,
c.MaxNumSelectionReq as MaxSelections c.MaxNumSelectionReq as MaxSelections,
c.IsInvertedGroup
FROM Items c FROM Items c
WHERE c.ParentItemID IN (:templateIds) WHERE c.ParentItemID IN (:templateIds)
AND c.IsActive = 1 AND c.IsActive = 1
@ -338,6 +343,7 @@ try {
"isTemplate": true, "isTemplate": true,
"requiresSelection": isNull(qTemplates.RequiresSelection[i]) ? false : (qTemplates.RequiresSelection[i] == 1), "requiresSelection": isNull(qTemplates.RequiresSelection[i]) ? false : (qTemplates.RequiresSelection[i] == 1),
"maxSelections": isNull(qTemplates.MaxSelections[i]) ? 0 : qTemplates.MaxSelections[i], "maxSelections": isNull(qTemplates.MaxSelections[i]) ? 0 : qTemplates.MaxSelections[i],
"isInverted": isNull(qTemplates.IsInvertedGroup[i]) ? false : (qTemplates.IsInvertedGroup[i] == 1),
"options": options "options": options
}; };
} }

View file

@ -208,6 +208,7 @@
i.RequiresChildSelection, i.RequiresChildSelection,
i.MaxNumSelectionReq, i.MaxNumSelectionReq,
i.IsCollapsible, i.IsCollapsible,
i.IsInvertedGroup,
i.SortOrder, i.SortOrder,
i.StationID, i.StationID,
s.Name AS StationName, s.Name AS StationName,
@ -262,6 +263,7 @@
i.RequiresChildSelection, i.RequiresChildSelection,
i.MaxNumSelectionReq, i.MaxNumSelectionReq,
i.IsCollapsible, i.IsCollapsible,
i.IsInvertedGroup,
i.SortOrder, i.SortOrder,
i.StationID, i.StationID,
s.Name, s.Name,
@ -299,6 +301,7 @@
i.RequiresChildSelection, i.RequiresChildSelection,
i.MaxNumSelectionReq, i.MaxNumSelectionReq,
i.IsCollapsible, i.IsCollapsible,
i.IsInvertedGroup,
i.SortOrder, i.SortOrder,
i.StationID, i.StationID,
s.Name, s.Name,
@ -351,6 +354,7 @@
"RequiresChildSelection": 0, "RequiresChildSelection": 0,
"MaxNumSelectionReq": 0, "MaxNumSelectionReq": 0,
"IsCollapsible": 0, "IsCollapsible": 0,
"IsInvertedGroup": 0,
"SortOrder": qCategories.SortOrder, "SortOrder": qCategories.SortOrder,
"MenuID": isNull(qCategories.MenuID) ? 0 : val(qCategories.MenuID), "MenuID": isNull(qCategories.MenuID) ? 0 : val(qCategories.MenuID),
"StationID": "", "StationID": "",
@ -415,6 +419,7 @@
"RequiresChildSelection": q.RequiresChildSelection, "RequiresChildSelection": q.RequiresChildSelection,
"MaxNumSelectionReq": q.MaxNumSelectionReq, "MaxNumSelectionReq": q.MaxNumSelectionReq,
"IsCollapsible": q.IsCollapsible, "IsCollapsible": q.IsCollapsible,
"IsInvertedGroup": q.IsInvertedGroup,
"SortOrder": q.SortOrder, "SortOrder": q.SortOrder,
"MenuID": itemMenuID, "MenuID": itemMenuID,
"StationID": len(trim(q.StationID)) ? q.StationID : "", "StationID": len(trim(q.StationID)) ? q.StationID : "",
@ -436,6 +441,7 @@
tmpl.RequiresChildSelection as TemplateRequired, tmpl.RequiresChildSelection as TemplateRequired,
tmpl.MaxNumSelectionReq as TemplateMaxSelections, tmpl.MaxNumSelectionReq as TemplateMaxSelections,
tmpl.IsCollapsible as TemplateIsCollapsible, tmpl.IsCollapsible as TemplateIsCollapsible,
tmpl.IsInvertedGroup as TemplateIsInvertedGroup,
tl.SortOrder as TemplateSortOrder tl.SortOrder as TemplateSortOrder
FROM lt_ItemID_TemplateItemID tl FROM lt_ItemID_TemplateItemID tl
INNER JOIN Items tmpl ON tmpl.ID = tl.TemplateItemID AND tmpl.IsActive = 1 INNER JOIN Items tmpl ON tmpl.ID = tl.TemplateItemID AND tmpl.IsActive = 1
@ -520,6 +526,7 @@
"RequiresChildSelection": qTemplateLinks.TemplateRequired, "RequiresChildSelection": qTemplateLinks.TemplateRequired,
"MaxNumSelectionReq": qTemplateLinks.TemplateMaxSelections, "MaxNumSelectionReq": qTemplateLinks.TemplateMaxSelections,
"IsCollapsible": qTemplateLinks.TemplateIsCollapsible, "IsCollapsible": qTemplateLinks.TemplateIsCollapsible,
"IsInvertedGroup": qTemplateLinks.TemplateIsInvertedGroup,
"SortOrder": qTemplateLinks.TemplateSortOrder, "SortOrder": qTemplateLinks.TemplateSortOrder,
"StationID": "", "StationID": "",
"ItemName": "", "ItemName": "",
@ -544,6 +551,7 @@
"RequiresChildSelection": 0, "RequiresChildSelection": 0,
"MaxNumSelectionReq": 0, "MaxNumSelectionReq": 0,
"IsCollapsible": 0, "IsCollapsible": 0,
"IsInvertedGroup": 0,
"SortOrder": opt.SortOrder, "SortOrder": opt.SortOrder,
"StationID": "", "StationID": "",
"ItemName": "", "ItemName": "",

View file

@ -19,6 +19,7 @@ function saveOptionsRecursive(options, parentID, businessID) {
var requiresSelection = (structKeyExists(opt, "requiresSelection") && opt.requiresSelection) ? 1 : 0; var requiresSelection = (structKeyExists(opt, "requiresSelection") && opt.requiresSelection) ? 1 : 0;
var maxSelections = structKeyExists(opt, "maxSelections") ? val(opt.maxSelections) : 0; var maxSelections = structKeyExists(opt, "maxSelections") ? val(opt.maxSelections) : 0;
var isDefault = (structKeyExists(opt, "isDefault") && opt.isDefault) ? 1 : 0; var isDefault = (structKeyExists(opt, "isDefault") && opt.isDefault) ? 1 : 0;
var isInverted = (structKeyExists(opt, "isInverted") && opt.isInverted) ? 1 : 0;
var optionID = 0; var optionID = 0;
if (optDbId > 0) { if (optDbId > 0) {
@ -31,6 +32,7 @@ function saveOptionsRecursive(options, parentID, businessID) {
SortOrder = :sortOrder, SortOrder = :sortOrder,
RequiresChildSelection = :requiresSelection, RequiresChildSelection = :requiresSelection,
MaxNumSelectionReq = :maxSelections, MaxNumSelectionReq = :maxSelections,
IsInvertedGroup = :isInverted,
ParentItemID = :parentID ParentItemID = :parentID
WHERE ID = :optID WHERE ID = :optID
", { ", {
@ -41,18 +43,19 @@ function saveOptionsRecursive(options, parentID, businessID) {
isDefault: isDefault, isDefault: isDefault,
sortOrder: optSortOrder, sortOrder: optSortOrder,
requiresSelection: requiresSelection, requiresSelection: requiresSelection,
maxSelections: maxSelections maxSelections: maxSelections,
isInverted: isInverted
}); });
} else { } else {
queryTimed(" queryTimed("
INSERT INTO Items ( INSERT INTO Items (
BusinessID, ParentItemID, Name, Price, BusinessID, ParentItemID, Name, Price,
IsCheckedByDefault, SortOrder, IsActive, AddedOn, IsCheckedByDefault, SortOrder, IsActive, AddedOn,
RequiresChildSelection, MaxNumSelectionReq, CategoryID RequiresChildSelection, MaxNumSelectionReq, IsInvertedGroup, CategoryID
) VALUES ( ) VALUES (
:businessID, :parentID, :name, :price, :businessID, :parentID, :name, :price,
:isDefault, :sortOrder, 1, NOW(), :isDefault, :sortOrder, 1, NOW(),
:requiresSelection, :maxSelections, 0 :requiresSelection, :maxSelections, :isInverted, 0
) )
", { ", {
businessID: businessID, businessID: businessID,
@ -62,7 +65,8 @@ function saveOptionsRecursive(options, parentID, businessID) {
isDefault: isDefault, isDefault: isDefault,
sortOrder: optSortOrder, sortOrder: optSortOrder,
requiresSelection: requiresSelection, requiresSelection: requiresSelection,
maxSelections: maxSelections maxSelections: maxSelections,
isInverted: isInverted
}); });
var result = queryTimed("SELECT LAST_INSERT_ID() as newID"); var result = queryTimed("SELECT LAST_INSERT_ID() as newID");
@ -333,12 +337,14 @@ try {
queryTimed(" queryTimed("
UPDATE Items UPDATE Items
SET RequiresChildSelection = :requiresSelection, SET RequiresChildSelection = :requiresSelection,
MaxNumSelectionReq = :maxSelections MaxNumSelectionReq = :maxSelections,
IsInvertedGroup = :isInverted
WHERE ID = :modID WHERE ID = :modID
", { ", {
modID: modDbId, modID: modDbId,
requiresSelection: requiresSelection, requiresSelection: requiresSelection,
maxSelections: maxSelections maxSelections: maxSelections,
isInverted: (structKeyExists(mod, "isInverted") && mod.isInverted) ? 1 : 0
}); });
// Only save template options ONCE (first time we encounter this template) // Only save template options ONCE (first time we encounter this template)
@ -358,6 +364,7 @@ try {
SortOrder = :sortOrder, SortOrder = :sortOrder,
RequiresChildSelection = :requiresSelection, RequiresChildSelection = :requiresSelection,
MaxNumSelectionReq = :maxSelections, MaxNumSelectionReq = :maxSelections,
IsInvertedGroup = :isInverted,
ParentItemID = :parentID ParentItemID = :parentID
WHERE ID = :modID WHERE ID = :modID
", { ", {
@ -368,7 +375,8 @@ try {
isDefault: (mod.isDefault ?: false) ? 1 : 0, isDefault: (mod.isDefault ?: false) ? 1 : 0,
sortOrder: modSortOrder, sortOrder: modSortOrder,
requiresSelection: requiresSelection, requiresSelection: requiresSelection,
maxSelections: maxSelections maxSelections: maxSelections,
isInverted: (structKeyExists(mod, "isInverted") && mod.isInverted) ? 1 : 0
}); });
if (structKeyExists(mod, "options") && isArray(mod.options)) { if (structKeyExists(mod, "options") && isArray(mod.options)) {
@ -380,11 +388,11 @@ try {
INSERT INTO Items ( INSERT INTO Items (
BusinessID, ParentItemID, Name, Price, BusinessID, ParentItemID, Name, Price,
IsCheckedByDefault, SortOrder, IsActive, AddedOn, IsCheckedByDefault, SortOrder, IsActive, AddedOn,
RequiresChildSelection, MaxNumSelectionReq, CategoryID RequiresChildSelection, MaxNumSelectionReq, IsInvertedGroup, CategoryID
) VALUES ( ) VALUES (
:businessID, :parentID, :name, :price, :businessID, :parentID, :name, :price,
:isDefault, :sortOrder, 1, NOW(), :isDefault, :sortOrder, 1, NOW(),
:requiresSelection, :maxSelections, 0 :requiresSelection, :maxSelections, :isInverted, 0
) )
", { ", {
businessID: businessID, businessID: businessID,
@ -394,7 +402,8 @@ try {
isDefault: (mod.isDefault ?: false) ? 1 : 0, isDefault: (mod.isDefault ?: false) ? 1 : 0,
sortOrder: modSortOrder, sortOrder: modSortOrder,
requiresSelection: requiresSelection, requiresSelection: requiresSelection,
maxSelections: maxSelections maxSelections: maxSelections,
isInverted: (structKeyExists(mod, "isInverted") && mod.isInverted) ? 1 : 0
}); });
modResult = queryTimed("SELECT LAST_INSERT_ID() as newModID"); modResult = queryTimed("SELECT LAST_INSERT_ID() as newModID");

View file

@ -124,6 +124,7 @@
i.Name, i.Name,
i.ParentItemID, i.ParentItemID,
i.IsCheckedByDefault, i.IsCheckedByDefault,
i.IsInvertedGroup,
i.StationID, i.StationID,
parent.Name AS ItemParentName parent.Name AS ItemParentName
FROM OrderLineItems oli FROM OrderLineItems oli
@ -139,7 +140,7 @@
<cfset arrayAppend(lineItems, { <cfset arrayAppend(lineItems, {
"OrderLineItemID": qLineItems.ID, "OrderLineItemID": qLineItems.ID,
"ParentOrderLineItemID": qLineItems.ParentOrderLineItemID, "ParentOrderLineItemID": qLineItems.ParentOrderLineItemID,
"ItemID": qLineItems.ID, "ItemID": qLineItems.ItemID,
"Price": qLineItems.Price, "Price": qLineItems.Price,
"Quantity": qLineItems.Quantity, "Quantity": qLineItems.Quantity,
"Remark": qLineItems.Remark, "Remark": qLineItems.Remark,
@ -147,11 +148,40 @@
"ParentItemID": qLineItems.ParentItemID, "ParentItemID": qLineItems.ParentItemID,
"ItemParentName": qLineItems.ItemParentName, "ItemParentName": qLineItems.ItemParentName,
"IsCheckedByDefault": qLineItems.IsCheckedByDefault, "IsCheckedByDefault": qLineItems.IsCheckedByDefault,
"IsInvertedGroup": qLineItems.IsInvertedGroup,
"StationID": qLineItems.StationID, "StationID": qLineItems.StationID,
"StatusID": val(qLineItems.StatusID) "StatusID": val(qLineItems.StatusID)
})> })>
</cfloop> </cfloop>
<!--- For inverted modifier groups, compute removed defaults --->
<cfloop array="#lineItems#" index="li">
<cfif li.IsInvertedGroup>
<!--- This is an inverted modifier group in the order — find defaults NOT ordered --->
<cfset qRemovedDefaults = queryTimed("
SELECT i.Name
FROM Items i
WHERE i.ParentItemID = ?
AND i.IsActive = 1
AND i.IsCheckedByDefault = b'1'
AND i.ID NOT IN (
SELECT oli2.ItemID FROM OrderLineItems oli2
WHERE oli2.OrderID = ? AND oli2.ParentOrderLineItemID = ? AND oli2.IsDeleted = b'0'
)
ORDER BY i.SortOrder
", [
{ value = li.ItemID, cfsqltype = "cf_sql_integer" },
{ value = qOrders.ID, cfsqltype = "cf_sql_integer" },
{ value = li.OrderLineItemID, cfsqltype = "cf_sql_integer" }
], { datasource = "payfrit" })>
<cfset removedNames = []>
<cfloop query="qRemovedDefaults">
<cfset arrayAppend(removedNames, qRemovedDefaults.Name)>
</cfloop>
<cfset li["RemovedDefaults"] = removedNames>
</cfif>
</cfloop>
<!--- Determine order type name ---> <!--- Determine order type name --->
<cfset orderTypeName = ""> <cfset orderTypeName = "">
<cfswitch expression="#qOrders.OrderTypeID#"> <cfswitch expression="#qOrders.OrderTypeID#">

View file

@ -397,33 +397,33 @@ function renderAllModifiers(modifiers, allItems) {
const leafModifiers = []; const leafModifiers = [];
function collectLeafModifiers(mods, depth = 0) { function collectLeafModifiers(mods, depth = 0) {
console.log(` collectLeafModifiers depth=${depth}, processing ${mods.length} mods:`, mods.map(m => m.Name));
mods.forEach(mod => { mods.forEach(mod => {
// Skip default modifiers - only show customizations // Inverted groups: show removed defaults with "NO" prefix instead of listing all selected defaults
if (mod.IsCheckedByDefault) { if (mod.IsInvertedGroup || mod.ISINVERTEDGROUP) {
console.log(` Skipping default modifier: ${mod.Name}`); const removed = mod.RemovedDefaults || mod.REMOVEDDEFAULTS || [];
if (removed.length > 0) {
removed.forEach(name => {
leafModifiers.push({ mod: { Name: 'NO ' + name, ItemParentName: mod.Name }, path: [] });
});
}
return; return;
} }
// Skip default modifiers - only show customizations
if (mod.IsCheckedByDefault) return;
const children = allItems.filter(item => item.ParentOrderLineItemID === mod.OrderLineItemID); const children = allItems.filter(item => item.ParentOrderLineItemID === mod.OrderLineItemID);
console.log(` Mod: ${mod.Name} (ID: ${mod.OrderLineItemID}) has ${children.length} children`);
if (children.length === 0) { if (children.length === 0) {
// This is a leaf node (actual selection)
const path = getModifierPath(mod); const path = getModifierPath(mod);
console.log(` -> LEAF, path: ${path.join(' > ')}`);
leafModifiers.push({ mod, path }); leafModifiers.push({ mod, path });
} else { } else {
// Has children, recurse deeper
collectLeafModifiers(children, depth + 1); collectLeafModifiers(children, depth + 1);
} }
}); });
} }
collectLeafModifiers(modifiers); collectLeafModifiers(modifiers);
console.log(` Total leaf modifiers found: ${leafModifiers.length}`);
leafModifiers.forEach(({ mod }) => { leafModifiers.forEach(({ mod }) => {
// Use ItemParentName (the category/template name) if available, otherwise just show the item name
// This gives us "Drink Choice: Coke" instead of "Double Double Combo: Coke"
const displayText = mod.ItemParentName const displayText = mod.ItemParentName
? `${mod.ItemParentName}: ${mod.Name}` ? `${mod.ItemParentName}: ${mod.Name}`
: mod.Name; : mod.Name;

View file

@ -2245,6 +2245,16 @@
<p style="color: var(--gray-500); font-size: 11px; margin: 4px 0 0;"> <p style="color: var(--gray-500); font-size: 11px; margin: 4px 0 0;">
Required = customer must select at least one option. Max 0 = unlimited. Required = customer must select at least one option. Max 0 = unlimited.
</p> </p>
<div class="property-group" style="margin-top: 12px;">
<label>Inverted Display</label>
<select onchange="MenuBuilder.updateOption('${parent.id}', '${option.id}', 'isInverted', this.value === 'true')">
<option value="false" ${!option.isInverted ? 'selected' : ''}>No</option>
<option value="true" ${option.isInverted ? 'selected' : ''}>Yes</option>
</select>
<p style="color: var(--gray-500); font-size: 11px; margin: 4px 0 0;">
When enabled, KDS and cart only show removed items (e.g., "NO Mustard") instead of listing all defaults.
</p>
</div>
</div> </div>
` : ''} ` : ''}
<div class="property-group"> <div class="property-group">
@ -2419,6 +2429,7 @@
sortOrder: obj.sortOrder || 0, sortOrder: obj.sortOrder || 0,
requiresSelection: obj.requiresSelection || false, requiresSelection: obj.requiresSelection || false,
maxSelections: obj.maxSelections || 0, maxSelections: obj.maxSelections || 0,
isInverted: obj.isInverted || false,
options: [] options: []
}; };
@ -4212,6 +4223,7 @@
${mod.isDefault ? '<span class="item-type-badge">Default</span>' : ''} ${mod.isDefault ? '<span class="item-type-badge">Default</span>' : ''}
${mod.requiresSelection ? '<span class="item-type-badge" style="background: #dc3545; color: white;">Required</span>' : ''} ${mod.requiresSelection ? '<span class="item-type-badge" style="background: #dc3545; color: white;">Required</span>' : ''}
${mod.maxSelections > 0 ? `<span class="item-type-badge">Max ${mod.maxSelections}</span>` : ''} ${mod.maxSelections > 0 ? `<span class="item-type-badge">Max ${mod.maxSelections}</span>` : ''}
${mod.isInverted ? '<span class="item-type-badge" style="background: #6f42c1; color: white;">Inverted</span>' : ''}
${hasOptions ? `<span class="item-type-badge">${mod.options.length} options</span>` : ''} ${hasOptions ? `<span class="item-type-badge">${mod.options.length} options</span>` : ''}
</div> </div>
</div> </div>