From a42d151c93cc81434ddfb139c5aff2217c9cc5bf Mon Sep 17 00:00:00 2001 From: 9Lukas5 Date: Thu, 29 Jan 2026 20:01:32 +0100 Subject: [PATCH] allow add full recipe required ingredient amount This modifies the bootbox on to add recipe ingredients to the shopping list in a way, that it - button to open bootbox is always enabled - popup lists all* ingredients, independent of fulfillment state - two new quick-select buttons to check only missing or all ingredients - radio button selection to define if the full or only missing amount shall be added to the shopping list *This still excludes ingredients which have the stock check disabled in a recipe. In order to get this information, the RecipesController was modified that it adds the data from the recipe ingredient setting as new additional field in the recipePositionsResolved. closes https://github.com/grocy/grocy/issues/686 --- controllers/RecipesApiController.php | 8 +++- controllers/RecipesController.php | 8 +++- public/viewjs/recipes.js | 34 ++++++++++++++-- services/RecipesService.php | 25 +++++++++--- views/recipes.blade.php | 59 +++++++++++++++++++++++++--- 5 files changed, 116 insertions(+), 18 deletions(-) diff --git a/controllers/RecipesApiController.php b/controllers/RecipesApiController.php index 719a5aae..23e349a2 100644 --- a/controllers/RecipesApiController.php +++ b/controllers/RecipesApiController.php @@ -16,13 +16,19 @@ class RecipesApiController extends BaseApiController $requestBody = $this->GetParsedAndFilteredRequestBody($request); $excludedProductIds = null; + $ignoreStock = false; if ($requestBody !== null && array_key_exists('excludedProductIds', $requestBody)) { $excludedProductIds = $requestBody['excludedProductIds']; } - $this->getRecipesService()->AddNotFulfilledProductsToShoppingList($args['recipeId'], $excludedProductIds); + if ($requestBody !== null && array_key_exists('ignoreStock', $requestBody)) + { + $ignoreStock = $requestBody['ignoreStock']; + } + + $this->getRecipesService()->AddNotFulfilledProductsToShoppingList($args['recipeId'], $excludedProductIds, $ignoreStock); return $this->EmptyApiResponse($response); } diff --git a/controllers/RecipesController.php b/controllers/RecipesController.php index f0c4305a..74ee5b29 100644 --- a/controllers/RecipesController.php +++ b/controllers/RecipesController.php @@ -105,10 +105,16 @@ class RecipesController extends BaseController $totalCalories = FindObjectInArrayByPropertyValue($recipesResolved, 'recipe_id', $selectedRecipe->id)->calories; } + $recipePositionsResolved = $this->getDatabase()->recipes_pos_resolved()->where('recipe_id', $selectedRecipe->id); + foreach ($recipePositionsResolved as $pos) + { + $pos["recipe_pos_data"] = $this->getDatabase()->recipes_pos($pos->recipe_pos_id); + } + $viewData = [ 'recipes' => $recipes, 'recipesResolved' => $recipesResolved, - 'recipePositionsResolved' => $this->getDatabase()->recipes_pos_resolved()->where('recipe_id', $selectedRecipe->id), + 'recipePositionsResolved' => $recipePositionsResolved, 'selectedRecipe' => $selectedRecipe, 'products' => $this->getDatabase()->products(), 'quantityUnits' => $this->getDatabase()->quantity_units(), diff --git a/public/viewjs/recipes.js b/public/viewjs/recipes.js index 0ce9df68..d55b0386 100644 --- a/public/viewjs/recipes.js +++ b/public/viewjs/recipes.js @@ -190,9 +190,13 @@ $(document).on('click', '.recipe-shopping-list', function(e) { var objectName = $(e.currentTarget).attr('data-recipe-name'); var objectId = $(e.currentTarget).attr('data-recipe-id'); + var popUpTemplate = $("#missing-recipe-pos-list")[0]; // save template html for later + var popUpHtml = popUpTemplate.outerHTML.replace("d-none", ""); // prepare visible html for current popup + var popUpTemplateParent = popUpTemplate.parentElement // remember where the template element was + $("#missing-recipe-pos-list").remove() // delete the template from the dom, as we are about to add it into the popup bootbox.confirm({ - message: __t('Are you sure you want to put all missing ingredients for recipe "%s" on the shopping list?', objectName) + "

" + __t("Uncheck ingredients to not put them on the shopping list") + ":" + $("#missing-recipe-pos-list")[0].outerHTML.replace("d-none", ""), + message: __t('Are you sure you want to put all selected ingredients for recipe "%s" on the shopping list?', objectName) + "

" + __t("Uncheck ingredients to not put them on the shopping list") + ":" + popUpHtml, closeButton: false, buttons: { confirm: { @@ -211,12 +215,16 @@ $(document).on('click', '.recipe-shopping-list', function(e) Grocy.FrontendHelpers.BeginUiBusy(); var excludedProductIds = new Array(); - $(".missing-recipe-pos-product-checkbox:checkbox:not(:checked)").each(function() + $(".missing-recipe-pos-product-checkbox").each(function() { - excludedProductIds.push($(this).data("product-id")); + if ($(this).data("ignore") || !$(this)[0].checked) + { + excludedProductIds.push($(this).data("product-id")); + } }); + var ignoreStock = $("#missing-recipe-pos-list-full-recipe")[0]; - Grocy.Api.Post('recipes/' + objectId + '/add-not-fulfilled-products-to-shoppinglist', { "excludedProductIds": excludedProductIds }, + Grocy.Api.Post('recipes/' + objectId + '/add-not-fulfilled-products-to-shoppinglist', { "excludedProductIds": excludedProductIds, "ignoreStock": ignoreStock.checked }, function(result) { window.location.reload(); @@ -228,10 +236,28 @@ $(document).on('click', '.recipe-shopping-list', function(e) } ); } + + popUpTemplateParent.append(popUpTemplate); // restore template, in case we don't reload the page and need the popup again } }); }); +$(document).on('click', '#missing-recipe-pos-list-select-missing', function(e) +{ + $(".missing-recipe-pos-product-checkbox").each(function() + { + $(this)[0].checked = !$(this).data("need-fulfilled"); + }); +}); + +$(document).on('click', '#missing-recipe-pos-list-select-all', function(e) +{ + $(".missing-recipe-pos-product-checkbox").each(function() + { + $(this)[0].checked = true; + }); +}); + $(".recipe-consume").on('click', function(e) { var objectName = $(e.currentTarget).attr('data-recipe-name'); diff --git a/services/RecipesService.php b/services/RecipesService.php index bdcfbf02..552e5f92 100644 --- a/services/RecipesService.php +++ b/services/RecipesService.php @@ -11,7 +11,7 @@ class RecipesService extends BaseService const RECIPE_TYPE_MEALPLAN_SHADOW = 'mealplan-shadow'; // A recipe per meal plan recipe (for separated stock fulfillment checking) => name = YYYY-MM-DD# const RECIPE_TYPE_NORMAL = 'normal'; // Normal / manually created recipes - public function AddNotFulfilledProductsToShoppingList($recipeId, $excludedProductIds = null) + public function AddNotFulfilledProductsToShoppingList($recipeId, $excludedProductIds = null, $ignoreStock = false) { $recipe = $this->getDataBase()->recipes($recipeId); $recipePositions = $this->GetRecipesPosResolved(); @@ -26,13 +26,26 @@ class RecipesService extends BaseService if ($recipePosition->recipe_id == $recipeId && !in_array($recipePosition->product_id, $excludedProductIds)) { $product = $this->getDataBase()->products($recipePosition->product_id); - $toOrderAmount = round(($recipePosition->missing_amount - $recipePosition->amount_on_shopping_list), 2); - $quId = $product->qu_id_purchase; - if ($recipe->not_check_shoppinglist == 1) - { - $toOrderAmount = round($recipePosition->missing_amount, 2); + // Determine order amount + // First, define the base amount, depending on if the full recipe amount or just the missing amount shall be used + if ($ignoreStock) { + $toOrderAmount = $recipePosition->recipe_amount; } + else { + $toOrderAmount = $recipePosition->missing_amount; + } + + // Then, decide if on top of the base amount, the shopping cart shall be considered as well + if ($recipe->not_check_shoppinglist == 0) + { + $toOrderAmount = round($toOrderAmount - $recipePosition->amount_on_shopping_list, 2); + } + else { + $toOrderAmount = round($toOrderAmount, 2); + } + + $quId = $product->qu_id_purchase; // When the recipe ingredient option "Only check if any amount is in stock" is enabled, // any QU can be used and the amount is not based on qu_stock then diff --git a/views/recipes.blade.php b/views/recipes.blade.php index fefb9edd..1e43ed52 100644 --- a/views/recipes.blade.php +++ b/views/recipes.blade.php @@ -28,6 +28,17 @@ column-count: 2; } } + + .missing-recipe-pos-list-radio-buttons { + display: flex; + flex-direction: row; + gap: 1em; + margin: 1em 0; + } + + .missing-recipe-pos-list-selection-buttons { + margin: 1em 0; + } @endpush @@ -340,7 +351,7 @@ data-recipe-name="{{ $recipe->name }}"> - - +
+ + +
@foreach($recipePositionsResolved as $recipePos) - @if(in_array($recipePos->recipe_id, $includedRecipeIdsAbsolute) && $recipePos->missing_amount > 0) + @if(in_array($recipePos->recipe_id, $includedRecipeIdsAbsolute))
+ class="list-group-item list-group-item-action list-group-item-primary missing-recipe-pos-select-button @if($recipePos->recipe_pos_data->not_check_stock_fulfillment == 1) d-none @endif">
+ data-ignore="{{ $recipePos->recipe_pos_data->not_check_stock_fulfillment }}" + data-need-fulfilled="{{ $recipePos->need_fulfilled }}" + @if(!$recipePos->need_fulfilled) checked @endif>
- {{ FindObjectInArrayByPropertyValue($products, 'id', $recipePos->product_id)->name }} + {{ FindObjectInArrayByPropertyValue($products, 'id', $recipePos->product_id)->name }}@if($recipePos->missing_amount > 0) ({{ $recipePos->missing_amount }} {{ $__t('missing') }})@endif
@endif @endforeach +
+ +
+ + +
+
+ + +
+
@endforeach