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
This commit is contained in:
9Lukas5 2026-01-29 20:01:32 +01:00
parent 2a124a3d47
commit a42d151c93
No known key found for this signature in database
GPG Key ID: 3203216F282460B6
5 changed files with 116 additions and 18 deletions

View File

@ -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);
}

View File

@ -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(),

View File

@ -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) + "<br><br>" + __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) + "<br><br>" + __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');

View File

@ -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#<meal_plan.id>
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

View File

@ -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;
}
</style>
@endpush
@ -340,7 +351,7 @@
data-recipe-name="{{ $recipe->name }}">
<i class="fa-solid fa-utensils"></i>
</a>
<a class="btn @if(!GROCY_FEATURE_FLAG_SHOPPINGLIST) d-none @endif recipe-shopping-list @if(FindObjectInArrayByPropertyValue($recipesResolved, 'recipe_id', $recipe->id)->need_fulfilled_with_shopping_list == 1) disabled @endif"
<a class="btn @if(!GROCY_FEATURE_FLAG_SHOPPINGLIST) d-none @endif recipe-shopping-list"
href="#"
data-toggle="tooltip"
title="{{ $__t('Put missing products on shopping list') }}"
@ -376,7 +387,7 @@
data-recipe-name="{{ $recipe->name }}">
<i class="fa-solid fa-utensils"></i>
</a>
<a class="btn recipe-shopping-list @if(FindObjectInArrayByPropertyValue($recipesResolved, 'recipe_id', $recipe->id)->need_fulfilled_with_shopping_list == 1) disabled @endif"
<a class="btn recipe-shopping-list"
href="#"
data-toggle="tooltip"
title="{{ $__t('Put missing products on shopping list') }}"
@ -580,20 +591,56 @@
<div id="missing-recipe-pos-list"
class="list-group d-none mt-3">
<div class="missing-recipe-pos-list-selection-buttons">
<button id="missing-recipe-pos-list-select-missing"
class="btn btn-secondary">{{ $__t('Only Missing') }}</button>
<button id="missing-recipe-pos-list-select-all"
class="btn btn-secondary">{{ $__t('All') }}</button>
</div>
@foreach($recipePositionsResolved as $recipePos)
@if(in_array($recipePos->recipe_id, $includedRecipeIdsAbsolute) && $recipePos->missing_amount > 0)
@if(in_array($recipePos->recipe_id, $includedRecipeIdsAbsolute))
<a href="#"
class="list-group-item list-group-item-action list-group-item-primary missing-recipe-pos-select-button">
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">
<div class="form-check form-check-inline">
<input class="form-check-input missing-recipe-pos-product-checkbox"
type="checkbox"
data-product-id="{{ $recipePos->product_id }}"
checked>
data-ignore="{{ $recipePos->recipe_pos_data->not_check_stock_fulfillment }}"
data-need-fulfilled="{{ $recipePos->need_fulfilled }}"
@if(!$recipePos->need_fulfilled) checked @endif>
</div>
{{ 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
</a>
@endif
@endforeach
<div class="missing-recipe-pos-list-radio-buttons">
<script>$('[data-toggle="tooltip"]').tooltip();</script>
<div>
<input class="missing-recipe-pos-list-only-missing"
type="radio"
id="missing-recipe-pos-list-only-missing"
name="missing-recipe-pos-list"
val="1"
checked>
<label
data-toggle="tooltip"
title="{{ $__t('Only the from stock missing amount will be added to the shopping list') }}"
for="missing-recipe-pos-list-only-missing">{{ $__t('missing amount') }}
</label>
</div>
<div>
<input class="missing-recipe-pos-list-only-missing"
type="radio"
id="missing-recipe-pos-list-full-recipe"
name="missing-recipe-pos-list"
val="2">
<label
data-toggle="tooltip"
title="{{ $__t('The full recipe required amount will be added to the shopping list, regardless of your current stock') }}"
for="missing-recipe-pos-list-full-recipe">{{ $__t('full recipe amount') }}
</label>
</div>
</div>
</div>
@endforeach
</div>