From e757a4140bcd9db954e84d5fae17633ea39b1f41 Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Wed, 7 Jan 2026 20:30:58 -0800 Subject: [PATCH] Add drink modifiers, unified schema improvements, and portal fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/Application.cfm | 3 + api/admin/addDrinkModifiers.cfm | 176 +++++++++++++++++++++++++++++ api/admin/copyDrinksToBigDeans.cfm | 141 +++++++++++++++++++++++ api/admin/debugDrinkStructure.cfm | 76 +++++++++++++ api/admin/debugTasks.cfm | 57 +++++++--- api/menu/items.cfm | 81 ++++++++++++- api/menu/updateStations.cfm | 8 +- api/orders/setLineItem.cfm | 4 +- api/tasks/listMine.cfm | 6 +- portal/station-assignment.html | 106 ++++++++++++++++- 10 files changed, 620 insertions(+), 38 deletions(-) create mode 100644 api/admin/addDrinkModifiers.cfm create mode 100644 api/admin/copyDrinksToBigDeans.cfm create mode 100644 api/admin/debugDrinkStructure.cfm diff --git a/api/Application.cfm b/api/Application.cfm index d24d92d..1b1410f 100644 --- a/api/Application.cfm +++ b/api/Application.cfm @@ -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/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/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 if (findNoCase("/api/setup/importBusiness.cfm", request._api_path)) request._api_isPublic = true; diff --git a/api/admin/addDrinkModifiers.cfm b/api/admin/addDrinkModifiers.cfm new file mode 100644 index 0000000..ec4cea2 --- /dev/null +++ b/api/admin/addDrinkModifiers.cfm @@ -0,0 +1,176 @@ + + + + + + +/** + * 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)); + diff --git a/api/admin/copyDrinksToBigDeans.cfm b/api/admin/copyDrinksToBigDeans.cfm new file mode 100644 index 0000000..40bb89d --- /dev/null +++ b/api/admin/copyDrinksToBigDeans.cfm @@ -0,0 +1,141 @@ + + + + + + +/** + * 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)); + diff --git a/api/admin/debugDrinkStructure.cfm b/api/admin/debugDrinkStructure.cfm new file mode 100644 index 0000000..e69e5ce --- /dev/null +++ b/api/admin/debugDrinkStructure.cfm @@ -0,0 +1,76 @@ + + + + + + +// 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)); + diff --git a/api/admin/debugTasks.cfm b/api/admin/debugTasks.cfm index fd49e4b..5ae8c55 100644 --- a/api/admin/debugTasks.cfm +++ b/api/admin/debugTasks.cfm @@ -3,29 +3,52 @@ - - - - - + - - - - - - - - - + + + 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" })> + #serializeJSON({ "OK": true, - "COLUMNS": cols, - "TASK_COUNT": qCount.cnt, - "SAMPLE_TASKS": tasks + "TASKS": tasks, + "STATS": { + "Total": qStats.Total, + "ClaimedNotCompleted": qStats.ClaimedNotCompleted, + "Unclaimed": qStats.Unclaimed, + "Completed": qStats.Completed + } })# diff --git a/api/menu/items.cfm b/api/menu/items.cfm index cb4f0be..8450aa2 100644 --- a/api/menu/items.cfm +++ b/api/menu/items.cfm @@ -70,7 +70,24 @@ - + + + + + + 0 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) - 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" } ], { datasource = "payfrit" } @@ -191,14 +208,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/api/orders/setLineItem.cfm b/api/orders/setLineItem.cfm index 6bbf7bd..1d0d487 100644 --- a/api/orders/setLineItem.cfm +++ b/api/orders/setLineItem.cfm @@ -50,7 +50,7 @@ FROM Items WHERE ItemParentItemID = ? AND ItemIsCheckedByDefault = 1 - AND ItemIsActive = b'1' + AND ItemIsActive = 1 ORDER BY ItemSortOrder, ItemID ", [ { value = arguments.ParentItemID, cfsqltype = "cf_sql_integer" } ], @@ -254,7 +254,7 @@ { datasource = "payfrit" } )> - + diff --git a/api/tasks/listMine.cfm b/api/tasks/listMine.cfm index 28fa5fe..d17327c 100644 --- a/api/tasks/listMine.cfm +++ b/api/tasks/listMine.cfm @@ -101,9 +101,9 @@ "TaskTitle": taskTitle, "TaskDetails": "", "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"), - "TaskCompletedOn": isNull(qTasks.TaskCompletedOn) ? "" : dateFormat(qTasks.TaskCompletedOn, "yyyy-mm-dd") & "T" & timeFormat(qTasks.TaskCompletedOn, "HH:mm:ss"), - "TaskStatusID": isNull(qTasks.TaskCompletedOn) ? 1 : 3, + "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) OR len(trim(qTasks.TaskCompletedOn)) EQ 0) ? "" : dateFormat(qTasks.TaskCompletedOn, "yyyy-mm-dd") & "T" & timeFormat(qTasks.TaskCompletedOn, "HH:mm:ss"), + "TaskStatusID": (isNull(qTasks.TaskCompletedOn) OR len(trim(qTasks.TaskCompletedOn)) EQ 0) ? 1 : 3, "TaskSourceType": "order", "TaskSourceID": qTasks.TaskOrderID, "TaskCategoryName": len(trim(qTasks.TaskCategoryName)) ? qTasks.TaskCategoryName : "General", diff --git a/portal/station-assignment.html b/portal/station-assignment.html index 475d4ac..5764b28 100644 --- a/portal/station-assignment.html +++ b/portal/station-assignment.html @@ -73,7 +73,45 @@ letter-spacing: 0.5px; color: var(--text-muted); 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 { @@ -521,8 +559,27 @@ if (filteredItems.length === 0) return; + // Get item IDs for this category (for dragging entire category) + const categoryItemIds = filteredItems.map(i => i.ItemID).join(','); + html += `
`; - html += `
${this.escapeHtml(catName)}
`; + html += ` +
+ + + + + + + + ${this.escapeHtml(catName)} + ${filteredItems.length} items +
+ `; filteredItems.forEach(item => { const assigned = this.assignments[item.ItemID]; @@ -643,6 +700,15 @@ onDragStart(event, itemId) { console.log('[StationAssignment] Drag start, itemId:', itemId); 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.target.classList.add('dragging'); }, @@ -685,10 +751,38 @@ stationCard.classList.remove('drag-over'); } - const itemId = parseInt(event.dataTransfer.getData('text/plain')); - console.log('[StationAssignment] Dropped itemId:', itemId); - if (itemId) { - this.assignToStation(itemId, stationId); + const droppedData = event.dataTransfer.getData('text/plain'); + console.log('[StationAssignment] Dropped data:', droppedData); + + // 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'); } },