Add drink modifiers, unified schema improvements, and portal fixes

Menu System:
- Unified schema with Categories table integration
- Virtual category headers with proper parent ID remapping
- Filter out legacy category headers when using new schema
- Add drink modifier endpoints for Fountain Soda (Size/Flavor)

Admin Tools:
- addDrinkModifiers.cfm - Add size/flavor modifiers to drinks
- copyDrinksToBigDeans.cfm - Copy drink items between businesses
- debugDrinkStructure.cfm - Debug drink item hierarchy

Portal:
- Station assignment improvements with better drag-drop
- Enhanced debug task viewer

API Fixes:
- Application.cfm updated with new admin endpoint allowlist
- setLineItem.cfm formatting cleanup
- listMine.cfm task query fixes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-07 20:30:58 -08:00
parent 634148f727
commit e757a4140b
10 changed files with 620 additions and 38 deletions

View file

@ -127,6 +127,9 @@ if (len(request._api_path)) {
if (findNoCase("/api/admin/beaconStatus.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/beaconStatus.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/updateBeaconMapping.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/updateBeaconMapping.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/setupBigDeansStations.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/admin/setupBigDeansStations.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/copyDrinksToBigDeans.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/debugDrinkStructure.cfm", request._api_path)) request._api_isPublic = true;
if (findNoCase("/api/admin/addDrinkModifiers.cfm", request._api_path)) request._api_isPublic = true;
// Setup/Import endpoints // Setup/Import endpoints
if (findNoCase("/api/setup/importBusiness.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/setup/importBusiness.cfm", request._api_path)) request._api_isPublic = true;

View file

@ -0,0 +1,176 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Add size and type modifiers to Big Dean's Fountain Soda
*/
response = { "OK": false, "SizesAdded": 0, "TypesAdded": 0 };
try {
bigDeansBusinessId = 27;
// Find the Fountain Soda item we created
qFountain = queryExecute("
SELECT ItemID, ItemName FROM Items
WHERE ItemBusinessID = :bizId AND ItemName = 'Fountain Soda'
", { bizId: bigDeansBusinessId }, { datasource: "payfrit" });
if (qFountain.recordCount == 0) {
response["ERROR"] = "Fountain Soda not found in Big Dean's menu";
writeOutput(serializeJSON(response));
abort;
}
fountainId = qFountain.ItemID;
response["FountainSodaID"] = fountainId;
// Update Fountain Soda to require child selection and be collapsible
queryExecute("
UPDATE Items
SET ItemRequiresChildSelection = 1, ItemIsCollapsible = 1
WHERE ItemID = :itemId
", { itemId: fountainId }, { datasource: "payfrit" });
// Check if modifiers already exist
qExisting = queryExecute("
SELECT COUNT(*) as cnt FROM Items WHERE ItemParentItemID = :parentId
", { parentId: fountainId }, { datasource: "payfrit" });
if (qExisting.cnt > 0) {
response["OK"] = true;
response["MESSAGE"] = "Modifiers already exist";
writeOutput(serializeJSON(response));
abort;
}
// Add Size group
qMaxItem = queryExecute("SELECT COALESCE(MAX(ItemID), 0) + 1 as nextId FROM Items", {}, { datasource: "payfrit" });
sizeGroupId = qMaxItem.nextId;
queryExecute("
INSERT INTO Items (
ItemID, ItemBusinessID, ItemCategoryID, ItemParentItemID,
ItemName, ItemDescription, ItemPrice, ItemIsActive,
ItemSortOrder, ItemIsCollapsible, ItemRequiresChildSelection,
ItemMaxNumSelectionReq, ItemAddedOn
) VALUES (
:itemId, :bizId, 0, :parentId,
'Size', 'Choose your size', 0, 1,
0, 1, 1, 1, NOW()
)
", {
itemId: sizeGroupId,
bizId: bigDeansBusinessId,
parentId: fountainId
}, { datasource: "payfrit" });
// Add Size options
sizes = [
{ name: "Small", price: 0, isDefault: 0 },
{ name: "Medium", price: 0.50, isDefault: 1 },
{ name: "Large", price: 1.00, isDefault: 0 }
];
sizesAdded = 0;
for (size in sizes) {
qMaxItem = queryExecute("SELECT COALESCE(MAX(ItemID), 0) + 1 as nextId FROM Items", {}, { datasource: "payfrit" });
queryExecute("
INSERT INTO Items (
ItemID, ItemBusinessID, ItemCategoryID, ItemParentItemID,
ItemName, ItemDescription, ItemPrice, ItemIsActive,
ItemSortOrder, ItemIsCollapsible, ItemIsCheckedByDefault,
ItemAddedOn
) VALUES (
:itemId, :bizId, 0, :parentId,
:name, '', :price, 1,
:sortOrder, 0, :isDefault,
NOW()
)
", {
itemId: qMaxItem.nextId,
bizId: bigDeansBusinessId,
parentId: sizeGroupId,
name: size.name,
price: size.price,
sortOrder: sizesAdded,
isDefault: size.isDefault
}, { datasource: "payfrit" });
sizesAdded++;
}
// Add Type/Flavor group
qMaxItem = queryExecute("SELECT COALESCE(MAX(ItemID), 0) + 1 as nextId FROM Items", {}, { datasource: "payfrit" });
typeGroupId = qMaxItem.nextId;
queryExecute("
INSERT INTO Items (
ItemID, ItemBusinessID, ItemCategoryID, ItemParentItemID,
ItemName, ItemDescription, ItemPrice, ItemIsActive,
ItemSortOrder, ItemIsCollapsible, ItemRequiresChildSelection,
ItemMaxNumSelectionReq, ItemAddedOn
) VALUES (
:itemId, :bizId, 0, :parentId,
'Flavor', 'Choose your drink', 0, 1,
1, 1, 1, 1, NOW()
)
", {
itemId: typeGroupId,
bizId: bigDeansBusinessId,
parentId: fountainId
}, { datasource: "payfrit" });
// Add Type options
types = [
{ name: "Coca-Cola", isDefault: 1 },
{ name: "Diet Coke", isDefault: 0 },
{ name: "Sprite", isDefault: 0 },
{ name: "Fanta Orange", isDefault: 0 },
{ name: "Lemonade", isDefault: 0 },
{ name: "Root Beer", isDefault: 0 },
{ name: "Dr Pepper", isDefault: 0 }
];
typesAdded = 0;
for (type in types) {
qMaxItem = queryExecute("SELECT COALESCE(MAX(ItemID), 0) + 1 as nextId FROM Items", {}, { datasource: "payfrit" });
queryExecute("
INSERT INTO Items (
ItemID, ItemBusinessID, ItemCategoryID, ItemParentItemID,
ItemName, ItemDescription, ItemPrice, ItemIsActive,
ItemSortOrder, ItemIsCollapsible, ItemIsCheckedByDefault,
ItemAddedOn
) VALUES (
:itemId, :bizId, 0, :parentId,
:name, '', 0, 1,
:sortOrder, 0, :isDefault,
NOW()
)
", {
itemId: qMaxItem.nextId,
bizId: bigDeansBusinessId,
parentId: typeGroupId,
name: type.name,
sortOrder: typesAdded,
isDefault: type.isDefault
}, { datasource: "payfrit" });
typesAdded++;
}
response["OK"] = true;
response["SizesAdded"] = sizesAdded;
response["TypesAdded"] = typesAdded;
response["SizeGroupID"] = sizeGroupId;
response["TypeGroupID"] = typeGroupId;
} catch (any e) {
response["ERROR"] = "server_error";
response["MESSAGE"] = e.message;
response["DETAIL"] = e.detail;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,141 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
/**
* Copy drinks from In-N-Out (BusinessID 17) to Big Dean's (BusinessID 27)
*/
response = { "OK": false, "ItemsCreated": 0, "CategoryCreated": false };
try {
bigDeansBusinessId = 27;
// First, check if Big Dean's has a Beverages/Drinks category
qExistingCat = queryExecute("
SELECT CategoryID, CategoryName FROM Categories
WHERE CategoryBusinessID = :bizId AND (CategoryName LIKE '%Drink%' OR CategoryName LIKE '%Beverage%')
", { bizId: bigDeansBusinessId }, { datasource: "payfrit" });
if (qExistingCat.recordCount > 0) {
drinksCategoryId = qExistingCat.CategoryID;
response["CategoryNote"] = "Using existing category: " & qExistingCat.CategoryName;
} else {
// Create a new Beverages category for Big Dean's
qMaxCat = queryExecute("SELECT COALESCE(MAX(CategoryID), 0) + 1 as nextId FROM Categories", {}, { datasource: "payfrit" });
drinksCategoryId = qMaxCat.nextId;
qMaxSort = queryExecute("
SELECT COALESCE(MAX(CategorySortOrder), 0) + 1 as nextSort FROM Categories WHERE CategoryBusinessID = :bizId
", { bizId: bigDeansBusinessId }, { datasource: "payfrit" });
queryExecute("
INSERT INTO Categories (CategoryID, CategoryBusinessID, CategoryParentCategoryID, CategoryName, CategorySortOrder, CategoryAddedOn)
VALUES (:catId, :bizId, 0, 'Beverages', :sortOrder, NOW())
", {
catId: drinksCategoryId,
bizId: bigDeansBusinessId,
sortOrder: qMaxSort.nextSort
}, { datasource: "payfrit" });
response["CategoryCreated"] = true;
response["CategoryNote"] = "Created new category: Beverages (ID: " & drinksCategoryId & ")";
}
// Drinks to add (from In-N-Out)
drinks = [
{ name: "Fountain Soda", price: 2.10, desc: "Coca-Cola, Diet Coke, Sprite, Fanta Orange, Lemonade" },
{ name: "Bottled Water", price: 1.50, desc: "" },
{ name: "Iced Tea", price: 2.25, desc: "Freshly brewed" },
{ name: "Coffee", price: 1.95, desc: "Hot brewed coffee" },
{ name: "Hot Cocoa", price: 2.25, desc: "" },
{ name: "Milk", price: 1.25, desc: "2% milk" },
{ name: "Orange Juice", price: 2.50, desc: "Fresh squeezed" },
{ name: "Milkshake", price: 4.95, desc: "Chocolate, Vanilla, or Strawberry - made with real ice cream", requiresChild: 1 }
];
itemsCreated = 0;
for (drink in drinks) {
// Check if item already exists
qExists = queryExecute("
SELECT ItemID FROM Items
WHERE ItemBusinessID = :bizId AND ItemName = :name AND ItemCategoryID = :catId
", { bizId: bigDeansBusinessId, name: drink.name, catId: drinksCategoryId }, { datasource: "payfrit" });
if (qExists.recordCount == 0) {
// Get next ItemID
qMaxItem = queryExecute("SELECT COALESCE(MAX(ItemID), 0) + 1 as nextId FROM Items", {}, { datasource: "payfrit" });
newItemId = qMaxItem.nextId;
queryExecute("
INSERT INTO Items (
ItemID, ItemBusinessID, ItemCategoryID, ItemParentItemID,
ItemName, ItemDescription, ItemPrice, ItemIsActive,
ItemSortOrder, ItemIsCollapsible, ItemRequiresChildSelection,
ItemAddedOn
) VALUES (
:itemId, :bizId, :catId, 0,
:name, :desc, :price, 1,
:sortOrder, 0, :requiresChild,
NOW()
)
", {
itemId: newItemId,
bizId: bigDeansBusinessId,
catId: drinksCategoryId,
name: drink.name,
desc: structKeyExists(drink, "desc") ? drink.desc : "",
price: drink.price,
sortOrder: itemsCreated,
requiresChild: structKeyExists(drink, "requiresChild") ? drink.requiresChild : 0
}, { datasource: "payfrit" });
itemsCreated++;
// If milkshake, add flavor options
if (drink.name == "Milkshake") {
flavors = ["Chocolate", "Vanilla", "Strawberry"];
flavorSort = 0;
for (flavor in flavors) {
qMaxOpt = queryExecute("SELECT COALESCE(MAX(ItemID), 0) + 1 as nextId FROM Items", {}, { datasource: "payfrit" });
queryExecute("
INSERT INTO Items (
ItemID, ItemBusinessID, ItemCategoryID, ItemParentItemID,
ItemName, ItemDescription, ItemPrice, ItemIsActive,
ItemSortOrder, ItemIsCollapsible, ItemIsCheckedByDefault,
ItemAddedOn
) VALUES (
:itemId, :bizId, 0, :parentId,
:name, '', 0, 1,
:sortOrder, 0, :isDefault,
NOW()
)
", {
itemId: qMaxOpt.nextId,
bizId: bigDeansBusinessId,
parentId: newItemId,
name: flavor,
sortOrder: flavorSort,
isDefault: (flavor == "Chocolate") ? 1 : 0
}, { datasource: "payfrit" });
flavorSort++;
}
}
}
}
response["OK"] = true;
response["ItemsCreated"] = itemsCreated;
response["CategoryID"] = drinksCategoryId;
} catch (any e) {
response["ERROR"] = "server_error";
response["MESSAGE"] = e.message;
response["DETAIL"] = e.detail;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -0,0 +1,76 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript>
// Find Fountain Drinks in In-N-Out (BusinessID 17) and show its hierarchy
response = { "OK": true };
try {
// Get Fountain Drinks item
qFountain = queryExecute("
SELECT ItemID, ItemName, ItemParentItemID, ItemPrice, ItemIsCollapsible, ItemRequiresChildSelection
FROM Items
WHERE ItemBusinessID = 17 AND ItemName LIKE '%Fountain%'
", {}, { datasource: "payfrit" });
response["FountainDrinks"] = [];
for (row in qFountain) {
fountainItem = {
"ItemID": row.ItemID,
"ItemName": row.ItemName,
"ItemPrice": row.ItemPrice,
"ItemIsCollapsible": row.ItemIsCollapsible,
"ItemRequiresChildSelection": row.ItemRequiresChildSelection,
"Children": []
};
// Get children of this item
qChildren = queryExecute("
SELECT ItemID, ItemName, ItemParentItemID, ItemPrice, ItemIsCollapsible, ItemRequiresChildSelection, ItemIsCheckedByDefault
FROM Items
WHERE ItemParentItemID = :parentId
ORDER BY ItemSortOrder
", { parentId: row.ItemID }, { datasource: "payfrit" });
for (child in qChildren) {
childItem = {
"ItemID": child.ItemID,
"ItemName": child.ItemName,
"ItemPrice": child.ItemPrice,
"ItemIsCollapsible": child.ItemIsCollapsible,
"ItemIsCheckedByDefault": child.ItemIsCheckedByDefault,
"Grandchildren": []
};
// Get grandchildren
qGrandchildren = queryExecute("
SELECT ItemID, ItemName, ItemPrice, ItemIsCheckedByDefault
FROM Items
WHERE ItemParentItemID = :parentId
ORDER BY ItemSortOrder
", { parentId: child.ItemID }, { datasource: "payfrit" });
for (gc in qGrandchildren) {
arrayAppend(childItem.Grandchildren, {
"ItemID": gc.ItemID,
"ItemName": gc.ItemName,
"ItemPrice": gc.ItemPrice,
"ItemIsCheckedByDefault": gc.ItemIsCheckedByDefault
});
}
arrayAppend(fountainItem.Children, childItem);
}
arrayAppend(response.FountainDrinks, fountainItem);
}
} catch (any e) {
response["OK"] = false;
response["ERROR"] = e.message;
}
writeOutput(serializeJSON(response));
</cfscript>

View file

@ -3,29 +3,52 @@
<cfcontent type="application/json; charset=utf-8"> <cfcontent type="application/json; charset=utf-8">
<cftry> <cftry>
<cfset qDesc = queryExecute("DESCRIBE Tasks", [], { datasource = "payfrit" })> <cfset qTasks = queryExecute("
<cfset cols = []> SELECT
<cfloop query="qDesc"> t.TaskID,
<cfset arrayAppend(cols, { "Field": qDesc.Field, "Type": qDesc.Type })> t.TaskBusinessID,
</cfloop> t.TaskOrderID,
t.TaskClaimedByUserID,
t.TaskClaimedOn,
t.TaskCompletedOn,
o.OrderStatusID
FROM Tasks t
LEFT JOIN Orders o ON o.OrderID = t.TaskOrderID
ORDER BY t.TaskID DESC
LIMIT 20
", [], { datasource = "payfrit" })>
<cfset qCount = queryExecute("SELECT COUNT(*) AS cnt FROM Tasks", [], { datasource = "payfrit" })>
<cfset qAll = queryExecute("SELECT * FROM Tasks LIMIT 5", [], { datasource = "payfrit" })>
<cfset tasks = []> <cfset tasks = []>
<cfloop query="qAll"> <cfloop query="qTasks">
<cfset row = {}> <cfset arrayAppend(tasks, {
<cfloop list="#qAll.columnList#" index="col"> "TaskID": qTasks.TaskID,
<cfset row[col] = qAll[col]> "TaskBusinessID": qTasks.TaskBusinessID,
</cfloop> "TaskOrderID": qTasks.TaskOrderID,
<cfset arrayAppend(tasks, row)> "TaskClaimedByUserID": qTasks.TaskClaimedByUserID,
"TaskClaimedOn": isNull(qTasks.TaskClaimedOn) ? "NULL" : dateTimeFormat(qTasks.TaskClaimedOn, "yyyy-mm-dd HH:nn:ss"),
"TaskCompletedOn": isNull(qTasks.TaskCompletedOn) ? "NULL" : dateTimeFormat(qTasks.TaskCompletedOn, "yyyy-mm-dd HH:nn:ss"),
"OrderStatusID": isNull(qTasks.OrderStatusID) ? "NULL" : qTasks.OrderStatusID
})>
</cfloop> </cfloop>
<cfset qStats = queryExecute("
SELECT
COUNT(*) as Total,
SUM(CASE WHEN TaskClaimedByUserID > 0 AND TaskCompletedOn IS NULL THEN 1 ELSE 0 END) as ClaimedNotCompleted,
SUM(CASE WHEN TaskClaimedByUserID = 0 OR TaskClaimedByUserID IS NULL THEN 1 ELSE 0 END) as Unclaimed,
SUM(CASE WHEN TaskCompletedOn IS NOT NULL THEN 1 ELSE 0 END) as Completed
FROM Tasks
", [], { datasource = "payfrit" })>
<cfoutput>#serializeJSON({ <cfoutput>#serializeJSON({
"OK": true, "OK": true,
"COLUMNS": cols, "TASKS": tasks,
"TASK_COUNT": qCount.cnt, "STATS": {
"SAMPLE_TASKS": tasks "Total": qStats.Total,
"ClaimedNotCompleted": qStats.ClaimedNotCompleted,
"Unclaimed": qStats.Unclaimed,
"Completed": qStats.Completed
}
})#</cfoutput> })#</cfoutput>
<cfcatch> <cfcatch>

View file

@ -70,7 +70,24 @@
<cfif hasCategoriesData> <cfif hasCategoriesData>
<!--- Use Categories table with ItemCategoryID ---> <!--- Use Categories table with ItemCategoryID --->
<!--- Only return items that have a valid CategoryID (actual menu items, not category headers) ---> <!--- First, get category headers as virtual items --->
<cfset qCategories = queryExecute(
"
SELECT
CategoryID,
CategoryName,
CategorySortOrder
FROM Categories
WHERE CategoryBusinessID = ?
ORDER BY CategorySortOrder
",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" }
)>
<!--- Get menu items --->
<!--- Exclude old-style category headers (ParentID=0 AND CategoryID=0 AND no children with CategoryID>0) --->
<!--- These are legacy category headers that should be replaced by Categories table entries --->
<cfset q = queryExecute( <cfset q = queryExecute(
" "
SELECT SELECT
@ -91,14 +108,14 @@
s.StationName, s.StationName,
s.StationColor s.StationColor
FROM Items i FROM Items i
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID LEFT JOIN Categories c ON c.CategoryID = i.ItemCategoryID
LEFT JOIN Stations s ON s.StationID = i.ItemStationID LEFT JOIN Stations s ON s.StationID = i.ItemStationID
WHERE i.ItemBusinessID = ? WHERE i.ItemBusinessID = ?
AND i.ItemIsActive = 1 AND i.ItemIsActive = 1
AND i.ItemCategoryID > 0
AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID) AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl WHERE tl.TemplateItemID = i.ItemID)
AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl2 WHERE tl2.TemplateItemID = i.ItemParentItemID) AND NOT EXISTS (SELECT 1 FROM ItemTemplateLinks tl2 WHERE tl2.TemplateItemID = i.ItemParentItemID)
ORDER BY c.CategorySortOrder, i.ItemSortOrder, i.ItemID AND NOT (i.ItemParentItemID = 0 AND i.ItemCategoryID = 0 AND i.ItemPrice = 0)
ORDER BY COALESCE(c.CategorySortOrder, 999), i.ItemSortOrder, i.ItemID
", ",
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ], [ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
{ datasource = "payfrit" } { datasource = "payfrit" }
@ -191,14 +208,66 @@
</cfif> </cfif>
<cfset rows = []> <cfset rows = []>
<!--- For unified schema with Categories table, add category headers first --->
<cfif newSchemaActive AND isDefined("qCategories")>
<cfloop query="qCategories">
<!--- Add category as a virtual parent item --->
<!--- Use CategoryID as ItemID, and set ItemCategoryID to same value --->
<!--- Set ItemParentItemID to 0 to mark as root level --->
<cfset arrayAppend(rows, {
"ItemID": qCategories.CategoryID,
"ItemCategoryID": qCategories.CategoryID,
"ItemCategoryName": qCategories.CategoryName,
"ItemName": qCategories.CategoryName,
"ItemDescription": "",
"ItemParentItemID": 0,
"ItemPrice": 0,
"ItemIsActive": 1,
"ItemIsCheckedByDefault": 0,
"ItemRequiresChildSelection": 0,
"ItemMaxNumSelectionReq": 0,
"ItemIsCollapsible": 0,
"ItemSortOrder": qCategories.CategorySortOrder,
"ItemStationID": "",
"ItemStationName": "",
"ItemStationColor": ""
})>
</cfloop>
</cfif>
<!--- Build a set of category IDs for quick lookup --->
<cfset categoryIdSet = {}>
<cfif isDefined("qCategories")>
<cfloop query="qCategories">
<cfset categoryIdSet[qCategories.CategoryID] = true>
</cfloop>
</cfif>
<cfloop query="q"> <cfloop query="q">
<!--- For unified schema with Categories: set ParentItemID to CategoryID for top-level items --->
<!--- Remap old parent IDs to category IDs --->
<cfset effectiveParentID = q.ItemParentItemID>
<cfif newSchemaActive AND isDefined("qCategories") AND q.ItemCategoryID GT 0>
<cfif q.ItemParentItemID EQ 0>
<!--- Item has no parent but has a category - link to category --->
<cfset effectiveParentID = q.ItemCategoryID>
<cfelseif structKeyExists(categoryIdSet, q.ItemParentItemID)>
<!--- Item's parent IS a category ID - this is correct, keep it --->
<cfset effectiveParentID = q.ItemParentItemID>
<cfelseif NOT structKeyExists(categoryIdSet, q.ItemParentItemID)>
<!--- Parent ID is an old-style category header - remap to CategoryID --->
<cfset effectiveParentID = q.ItemCategoryID>
</cfif>
</cfif>
<cfset arrayAppend(rows, { <cfset arrayAppend(rows, {
"ItemID": q.ItemID, "ItemID": q.ItemID,
"ItemCategoryID": q.ItemCategoryID, "ItemCategoryID": q.ItemCategoryID,
"ItemCategoryName": q.CategoryName, "ItemCategoryName": len(trim(q.CategoryName)) ? q.CategoryName : "",
"ItemName": q.ItemName, "ItemName": q.ItemName,
"ItemDescription": q.ItemDescription, "ItemDescription": q.ItemDescription,
"ItemParentItemID": q.ItemParentItemID, "ItemParentItemID": effectiveParentID,
"ItemPrice": q.ItemPrice, "ItemPrice": q.ItemPrice,
"ItemIsActive": q.ItemIsActive, "ItemIsActive": q.ItemIsActive,
"ItemIsCheckedByDefault": q.ItemIsCheckedByDefault, "ItemIsCheckedByDefault": q.ItemIsCheckedByDefault,

View file

@ -38,11 +38,11 @@
<cfset updateCount = 0> <cfset updateCount = 0>
<!--- First, clear all station assignments for items in this business ---> <!--- First, clear all station assignments for items in this business --->
<!--- Support both unified schema (ItemBusinessID) and legacy (via Categories) --->
<cfset queryExecute(" <cfset queryExecute("
UPDATE Items i UPDATE Items
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID SET ItemStationID = NULL
SET i.ItemStationID = NULL WHERE ItemBusinessID = ?
WHERE c.CategoryBusinessID = ?
", [ { value = BusinessID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })> ", [ { value = BusinessID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
<!--- Then apply the new assignments ---> <!--- Then apply the new assignments --->

View file

@ -50,7 +50,7 @@
FROM Items FROM Items
WHERE ItemParentItemID = ? WHERE ItemParentItemID = ?
AND ItemIsCheckedByDefault = 1 AND ItemIsCheckedByDefault = 1
AND ItemIsActive = b'1' AND ItemIsActive = 1
ORDER BY ItemSortOrder, ItemID ORDER BY ItemSortOrder, ItemID
", ",
[ { value = arguments.ParentItemID, cfsqltype = "cf_sql_integer" } ], [ { value = arguments.ParentItemID, cfsqltype = "cf_sql_integer" } ],
@ -254,7 +254,7 @@
{ datasource = "payfrit" } { datasource = "payfrit" }
)> )>
<cfif qItem.recordCount EQ 0 OR qItem.ItemIsActive NEQ true> <cfif qItem.recordCount EQ 0 OR qItem.ItemIsActive NEQ 1>
<cfset apiAbort({ "OK": false, "ERROR": "bad_item", "MESSAGE": "Item not found or inactive.", "DETAIL": "" })> <cfset apiAbort({ "OK": false, "ERROR": "bad_item", "MESSAGE": "Item not found or inactive.", "DETAIL": "" })>
</cfif> </cfif>

View file

@ -101,9 +101,9 @@
"TaskTitle": taskTitle, "TaskTitle": taskTitle,
"TaskDetails": "", "TaskDetails": "",
"TaskCreatedOn": dateFormat(qTasks.TaskAddedOn, "yyyy-mm-dd") & "T" & timeFormat(qTasks.TaskAddedOn, "HH:mm:ss"), "TaskCreatedOn": dateFormat(qTasks.TaskAddedOn, "yyyy-mm-dd") & "T" & timeFormat(qTasks.TaskAddedOn, "HH:mm:ss"),
"TaskClaimedOn": isNull(qTasks.TaskClaimedOn) ? "" : dateFormat(qTasks.TaskClaimedOn, "yyyy-mm-dd") & "T" & timeFormat(qTasks.TaskClaimedOn, "HH:mm:ss"), "TaskClaimedOn": (isNull(qTasks.TaskClaimedOn) OR len(trim(qTasks.TaskClaimedOn)) EQ 0) ? "" : dateFormat(qTasks.TaskClaimedOn, "yyyy-mm-dd") & "T" & timeFormat(qTasks.TaskClaimedOn, "HH:mm:ss"),
"TaskCompletedOn": isNull(qTasks.TaskCompletedOn) ? "" : dateFormat(qTasks.TaskCompletedOn, "yyyy-mm-dd") & "T" & timeFormat(qTasks.TaskCompletedOn, "HH:mm:ss"), "TaskCompletedOn": (isNull(qTasks.TaskCompletedOn) OR len(trim(qTasks.TaskCompletedOn)) EQ 0) ? "" : dateFormat(qTasks.TaskCompletedOn, "yyyy-mm-dd") & "T" & timeFormat(qTasks.TaskCompletedOn, "HH:mm:ss"),
"TaskStatusID": isNull(qTasks.TaskCompletedOn) ? 1 : 3, "TaskStatusID": (isNull(qTasks.TaskCompletedOn) OR len(trim(qTasks.TaskCompletedOn)) EQ 0) ? 1 : 3,
"TaskSourceType": "order", "TaskSourceType": "order",
"TaskSourceID": qTasks.TaskOrderID, "TaskSourceID": qTasks.TaskOrderID,
"TaskCategoryName": len(trim(qTasks.TaskCategoryName)) ? qTasks.TaskCategoryName : "General", "TaskCategoryName": len(trim(qTasks.TaskCategoryName)) ? qTasks.TaskCategoryName : "General",

View file

@ -73,7 +73,45 @@
letter-spacing: 0.5px; letter-spacing: 0.5px;
color: var(--text-muted); color: var(--text-muted);
margin-bottom: 8px; margin-bottom: 8px;
padding: 0 4px; padding: 8px 12px;
background: var(--bg-card);
border: 2px solid var(--border-color);
border-radius: 8px;
cursor: grab;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
}
.category-label:hover {
border-color: var(--primary);
background: rgba(0, 255, 136, 0.08);
}
.category-label:active {
cursor: grabbing;
}
.category-label.dragging {
opacity: 0.5;
transform: scale(0.98);
}
.category-label .drag-icon {
opacity: 0.5;
}
.category-label .cat-name {
flex: 1;
}
.category-label .cat-count {
font-size: 11px;
font-weight: normal;
background: var(--bg-secondary);
padding: 2px 8px;
border-radius: 10px;
} }
.draggable-item { .draggable-item {
@ -521,8 +559,27 @@
if (filteredItems.length === 0) return; if (filteredItems.length === 0) return;
// Get item IDs for this category (for dragging entire category)
const categoryItemIds = filteredItems.map(i => i.ItemID).join(',');
html += `<div class="category-group">`; html += `<div class="category-group">`;
html += `<div class="category-label">${this.escapeHtml(catName)}</div>`; html += `
<div class="category-label"
draggable="true"
data-category-items="${categoryItemIds}"
ondragstart="StationAssignment.onCategoryDragStart(event, '${categoryItemIds}')"
ondragend="StationAssignment.onDragEnd(event)">
<span class="drag-icon">
<svg width="14" height="14" 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>
</span>
<span class="cat-name">${this.escapeHtml(catName)}</span>
<span class="cat-count">${filteredItems.length} items</span>
</div>
`;
filteredItems.forEach(item => { filteredItems.forEach(item => {
const assigned = this.assignments[item.ItemID]; const assigned = this.assignments[item.ItemID];
@ -643,6 +700,15 @@
onDragStart(event, itemId) { onDragStart(event, itemId) {
console.log('[StationAssignment] Drag start, itemId:', itemId); console.log('[StationAssignment] Drag start, itemId:', itemId);
event.dataTransfer.setData('text/plain', itemId.toString()); event.dataTransfer.setData('text/plain', itemId.toString());
event.dataTransfer.setData('dragType', 'item');
event.dataTransfer.effectAllowed = 'move';
event.target.classList.add('dragging');
},
onCategoryDragStart(event, itemIds) {
console.log('[StationAssignment] Category drag start, itemIds:', itemIds);
event.dataTransfer.setData('text/plain', itemIds);
event.dataTransfer.setData('dragType', 'category');
event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.effectAllowed = 'move';
event.target.classList.add('dragging'); event.target.classList.add('dragging');
}, },
@ -685,10 +751,38 @@
stationCard.classList.remove('drag-over'); stationCard.classList.remove('drag-over');
} }
const itemId = parseInt(event.dataTransfer.getData('text/plain')); const droppedData = event.dataTransfer.getData('text/plain');
console.log('[StationAssignment] Dropped itemId:', itemId); console.log('[StationAssignment] Dropped data:', droppedData);
if (itemId) {
this.assignToStation(itemId, stationId); // Check if this is a category drop (multiple comma-separated IDs)
if (droppedData.includes(',')) {
// Category drop - assign all items
const itemIds = droppedData.split(',').map(id => parseInt(id.trim())).filter(id => id > 0);
console.log('[StationAssignment] Category drop, itemIds:', itemIds);
this.assignCategoryToStation(itemIds, stationId);
} else {
// Single item drop
const itemId = parseInt(droppedData);
if (itemId) {
this.assignToStation(itemId, stationId);
}
}
},
assignCategoryToStation(itemIds, stationId) {
const station = this.stations.find(s => s.StationID === stationId);
let assignedCount = 0;
itemIds.forEach(itemId => {
this.assignments[itemId] = stationId;
assignedCount++;
});
this.renderItems();
this.renderStations();
if (station && assignedCount > 0) {
this.toast(`${assignedCount} items assigned to ${station.StationName}`, 'success');
} }
}, },