Finished first version of meal planning (for now closes #146)

This commit is contained in:
Bernd Bestel 2019-05-07 19:48:14 +02:00
parent 3f53919ddd
commit 8504eb9b38
No known key found for this signature in database
GPG Key ID: 71BD34C0D4891300
9 changed files with 240 additions and 15 deletions

View File

@ -5,6 +5,7 @@
- => Can be configured under Master data / Userfields - => Can be configured under Master data / Userfields
- New feature: Meal planning - New feature: Meal planning
- Simple approach for the beginning (more to come): A week view where you can add recipes for each day (new menu entry in the sidebar, below calendar) - Simple approach for the beginning (more to come): A week view where you can add recipes for each day (new menu entry in the sidebar, below calendar)
- Of course it's also possible to put missing things directly on the shopping list from there, also for a complete week at once
- General improvements - General improvements
- The "expires soon" or "due soon" days (yelllow bar at the top of each overview page) can now be configured - The "expires soon" or "due soon" days (yelllow bar at the top of each overview page) can now be configured
- => New settings page for each area under the settings icon at the top right - => New settings page for each area under the settings icon at the top right

View File

@ -19,7 +19,14 @@ class RecipesController extends BaseController
public function Overview(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) public function Overview(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{ {
$recipes = $this->Database->recipes()->orderBy('name'); if (isset($request->getQueryParams()['include-internal']))
{
$recipes = $this->Database->recipes()->orderBy('name');
}
else
{
$recipes = $this->Database->recipes()->where('type', RecipesService::RECIPE_TYPE_NORMAL)->orderBy('name');
}
$recipesResolved = $this->RecipesService->GetRecipesResolved(); $recipesResolved = $this->RecipesService->GetRecipesResolved();
$selectedRecipe = null; $selectedRecipe = null;
@ -71,7 +78,7 @@ class RecipesController extends BaseController
$recipeId = $args['recipeId']; $recipeId = $args['recipeId'];
if ($recipeId == 'new') if ($recipeId == 'new')
{ {
$newRecipe = $this->Database->recipes()->createRow(array( $newRecipe = $this->Database->recipes()->where('type', RecipesService::RECIPE_TYPE_NORMAL)->createRow(array(
'name' => $this->LocalizationService->__t('New recipe') 'name' => $this->LocalizationService->__t('New recipe')
)); ));
$newRecipe->save(); $newRecipe->save();
@ -87,7 +94,7 @@ class RecipesController extends BaseController
'quantityunits' => $this->Database->quantity_units(), 'quantityunits' => $this->Database->quantity_units(),
'recipePositionsResolved' => $this->RecipesService->GetRecipesPosResolved(), 'recipePositionsResolved' => $this->RecipesService->GetRecipesPosResolved(),
'recipesResolved' => $this->RecipesService->GetRecipesResolved(), 'recipesResolved' => $this->RecipesService->GetRecipesResolved(),
'recipes' => $this->Database->recipes()->orderBy('name'), 'recipes' => $this->Database->recipes()->where('type', RecipesService::RECIPE_TYPE_NORMAL)->orderBy('name'),
'recipeNestings' => $this->Database->recipes_nestings()->where('recipe_id', $recipeId), 'recipeNestings' => $this->Database->recipes_nestings()->where('recipe_id', $recipeId),
'userfields' => $this->UserfieldsService->GetFields('recipes') 'userfields' => $this->UserfieldsService->GetFields('recipes')
]); ]);
@ -118,7 +125,7 @@ class RecipesController extends BaseController
public function MealPlan(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) public function MealPlan(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{ {
$recipes = $this->Database->recipes()->fetchAll(); $recipes = $this->Database->recipes()->where('type', RecipesService::RECIPE_TYPE_NORMAL)->fetchAll();
$events = array(); $events = array();
foreach($this->Database->meal_plan() as $mealPlanEntry) foreach($this->Database->meal_plan() as $mealPlanEntry)
@ -135,7 +142,9 @@ class RecipesController extends BaseController
return $this->AppContainer->view->render($response, 'mealplan', [ return $this->AppContainer->view->render($response, 'mealplan', [
'fullcalendarEventSources' => $events, 'fullcalendarEventSources' => $events,
'recipes' => $recipes 'recipes' => $recipes,
'internalRecipes' => $this->Database->recipes()->whereNot('type', RecipesService::RECIPE_TYPE_NORMAL)->fetchAll(),
'recipesResolved' => $this->RecipesService->GetRecipesResolved()
]); ]);
} }
} }

View File

@ -1236,3 +1236,11 @@ msgstr ""
msgid "Add recipe to %s" msgid "Add recipe to %s"
msgstr "" msgstr ""
msgid "%s serving"
msgid_plural "%s servings"
msgstr[0] ""
msgstr[1] ""
msgid "Week costs"
msgstr ""

54
migrations/0071.sql Normal file
View File

@ -0,0 +1,54 @@
ALTER TABLE meal_plan
ADD servings INTEGER DEFAULT 1;
ALTER TABLE recipes
ADD type TEXT DEFAULT 'normal';
CREATE INDEX ix_recipes ON recipes (
name,
type
);
CREATE TRIGGER create_internal_recipe AFTER INSERT ON meal_plan
BEGIN
-- Create a recipe per day
DELETE FROM recipes
WHERE name = NEW.day
AND type = 'mealplan-day';
INSERT OR REPLACE INTO recipes
(id, name, type)
VALUES
((SELECT MIN(id) - 1 FROM recipes), NEW.day, 'mealplan-day');
-- Create a recipe per week
DELETE FROM recipes
WHERE name = LTRIM(STRFTIME('%Y-%W', NEW.day), '0')
AND type = 'mealplan-week';
INSERT INTO recipes
(id, name, type)
VALUES
((SELECT MIN(id) - 1 FROM recipes), LTRIM(STRFTIME('%Y-%W', NEW.day), '0'), 'mealplan-week');
-- Delete all current nestings entries for the day and week recipe
DELETE FROM recipes_nestings
WHERE recipe_id IN (SELECT id FROM recipes WHERE name = NEW.day AND type = 'mealplan-day')
OR recipe_id IN (SELECT id FROM recipes WHERE name = NEW.day AND type = 'mealplan-week');
-- Add all recipes for this day as included recipes in the day-recipe
INSERT INTO recipes_nestings
(recipe_id, includes_recipe_id, servings)
SELECT (SELECT id FROM recipes WHERE name = NEW.day AND type = 'mealplan-day'), recipe_id, SUM(servings)
FROM meal_plan
WHERE day = NEW.day
GROUP BY recipe_id;
-- Add all recipes for this week as included recipes in the week-recipe
INSERT INTO recipes_nestings
(recipe_id, includes_recipe_id, servings)
SELECT (SELECT id FROM recipes WHERE name = LTRIM(STRFTIME('%Y-%W', NEW.day), '0') AND type = 'mealplan-week'), recipe_id, SUM(servings)
FROM meal_plan
WHERE STRFTIME('%Y-%W', day) = STRFTIME('%Y-%W', NEW.day)
GROUP BY recipe_id;
END;

View File

@ -16,6 +16,11 @@ String.prototype.isEmpty = function()
return (this.length === 0 || !this.trim()); return (this.length === 0 || !this.trim());
}; };
String.prototype.replaceAll = function(search, replacement)
{
return this.replace(new RegExp(search, "g"), replacement);
};
GetUriParam = function(key) GetUriParam = function(key)
{ {
var currentUri = decodeURIComponent(window.location.search.substring(1)); var currentUri = decodeURIComponent(window.location.search.substring(1));
@ -72,3 +77,16 @@ $.extend($.expr[":"],
return (elem.textContent || elem.innerText || "").toLowerCase().indexOf((match[3] || "").toLowerCase()) >= 0; return (elem.textContent || elem.innerText || "").toLowerCase().indexOf((match[3] || "").toLowerCase()) >= 0;
} }
}); });
FindObjectInArrayByPropertyValue = function(array, propertyName, propertyValue)
{
for (var i = 0; i < array.length; i++)
{
if (array[i][propertyName] == propertyValue)
{
return array[i];
}
}
return null;
}

View File

@ -482,15 +482,19 @@ $("#about-dialog-link").on("click", function()
}); });
}); });
$(".locale-number-format[data-format='currency']").each(function () function RefreshLocaleNumberDisplay()
{ {
$(this).text(parseFloat($(this).text()).toLocaleString(undefined, { style: "currency", currency: Grocy.Currency })); $(".locale-number-format[data-format='currency']").each(function()
}); {
$(this).text(parseFloat($(this).text()).toLocaleString(undefined, { style: "currency", currency: Grocy.Currency }));
});
$(".locale-number-format[data-format='quantity-amount']").each(function () $(".locale-number-format[data-format='quantity-amount']").each(function()
{ {
$(this).text(parseFloat($(this).text()).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 3 })); $(this).text(parseFloat($(this).text()).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 3 }));
}); });
}
RefreshLocaleNumberDisplay();
$(document).on("click", ".easy-link-copy-textbox", function() $(document).on("click", ".easy-link-copy-textbox", function()
{ {

View File

@ -12,21 +12,72 @@
"viewRender": function(view) "viewRender": function(view)
{ {
$(".fc-day-header").append('<a class="ml-1 btn btn-outline-dark btn-xs my-1 add-recipe-button" href="#"><i class="fas fa-plus"></i></a>'); $(".fc-day-header").append('<a class="ml-1 btn btn-outline-dark btn-xs my-1 add-recipe-button" href="#"><i class="fas fa-plus"></i></a>');
var weekRecipeName = view.start.year().toString() + "-" + (view.start.week() - 1).toString();
var weekRecipe = FindObjectInArrayByPropertyValue(internalRecipes, "name", weekRecipeName);
var weekCosts = 0;
var weekRecipeOrderMissingButtonHtml = "";
if (weekRecipe !== null)
{
weekCosts = FindObjectInArrayByPropertyValue(recipesResolved, "recipe_id", weekRecipe.id).costs;
var weekRecipeOrderMissingButtonDisabledClasses = "";
if (FindObjectInArrayByPropertyValue(recipesResolved, "recipe_id", weekRecipe.id).need_fulfilled_with_shopping_list == 1)
{
weekRecipeOrderMissingButtonDisabledClasses = "disabled";
}
weekRecipeOrderMissingButtonHtml = '<a class="ml-1 btn btn-outline-primary btn-xs recipe-order-missing-button ' + weekRecipeOrderMissingButtonDisabledClasses + '" href="#" data-toggle="tooltip" title="' + __t("Put missing products on shopping list") + '" data-recipe-id="' + weekRecipe.id.toString() + '" data-recipe-name="' + weekRecipe.name + '" data-recipe-type="' + weekRecipe.type + '"><i class="fas fa-cart-plus"></i></a>'
}
$(".fc-header-toolbar .fc-center").html("<h4>" + __t("Week costs") + ': <span class="locale-number-format" data-format="currency">' + weekCosts.toString() + "</span> " + weekRecipeOrderMissingButtonHtml + "</h4>");
}, },
"eventRender": function(event, element) "eventRender": function(event, element)
{ {
var recipe = JSON.parse(event.recipe); var recipe = JSON.parse(event.recipe);
var mealPlanEntry = JSON.parse(event.mealPlanEntry);
var resolvedRecipe = FindObjectInArrayByPropertyValue(recipesResolved, "recipe_id", recipe.id);
element.removeClass("fc-event"); element.removeClass("fc-event");
element.addClass("text-center"); element.addClass("text-center");
element.attr("data-recipe", event.recipe); element.attr("data-recipe", event.recipe);
element.attr("data-meal-plan-entry", event.mealPlanEntry); element.attr("data-meal-plan-entry", event.mealPlanEntry);
element.html('<h5 class="text-truncate">' + recipe.name + '<br><a class="ml-1 btn btn-outline-danger btn-xs remove-recipe-button" href="#"><i class="fas fa-trash"></i></a></h5>');
var recipeOrderMissingButtonDisabledClasses = "";
if (resolvedRecipe.need_fulfilled_with_shopping_list == 1)
{
recipeOrderMissingButtonDisabledClasses = "disabled";
}
var fulfillmentInfoHtml = __t('Enough in stock');
var fulfillmentIconHtml = '<i class="fas fa-check text-success"></i>';
if (resolvedRecipe.need_fulfilled != 1)
{
fulfillmentInfoHtml = __t('Not enough in stock');
var fulfillmentIconHtml = '<i class="fas fa-times text-danger"></i>';
}
element.html(' \
<div class="text-truncate"> \
<h5>' + recipe.name + '<h5> \
<h5 class="small">' + __n(mealPlanEntry.servings, "%s serving", "%s servings") + '</h5> \
<h5 class="small timeago-contextual">' + fulfillmentIconHtml + " " + fulfillmentInfoHtml + '</h5> \
<h5 class="small locale-number-format" data-format="currency">' + resolvedRecipe.costs + '<h5> \
<h5> \
<a class="ml-1 btn btn-outline-danger btn-xs remove-recipe-button" href="#"><i class="fas fa-trash"></i></a> \
<a class="ml-1 btn btn-outline-primary btn-xs recipe-order-missing-button ' + recipeOrderMissingButtonDisabledClasses + '" href="#" data-toggle="tooltip" title="' + __t("Put missing products on shopping list") + '" data-recipe-id="' + recipe.id.toString() + '" data-recipe-name="' + recipe.name + '" data-recipe-type="' + recipe.type + '"><i class="fas fa-cart-plus"></i></a> \
</h5> \
</div>');
if (recipe.picture_file_name && !recipe.picture_file_name.isEmpty()) if (recipe.picture_file_name && !recipe.picture_file_name.isEmpty())
{ {
element.html(element.html() + '<img src="' + U("/api/files/recipepictures/") + btoa(recipe.picture_file_name) + '" class="img-fluid">') element.html(element.html() + '<img src="' + U("/api/files/recipepictures/") + btoa(recipe.picture_file_name) + '" class="img-fluid">')
} }
} },
"eventAfterAllRender": function(view)
{
RefreshLocaleNumberDisplay();
},
}); });
$(document).on("click", ".add-recipe-button", function(e) $(document).on("click", ".add-recipe-button", function(e)
@ -98,3 +149,68 @@ Grocy.Components.RecipePicker.GetInputElement().keydown(function(event)
} }
} }
}); });
$(document).on("keyodwn", "#servings", function(e)
{
if (event.keyCode === 13) //Enter
{
event.preventDefault();
if (document.getElementById("add-recipe-form").checkValidity() === false) //There is at least one validation error
{
return false;
}
else
{
$("#save-add-recipe-button").click();
}
}
});
$(document).on('click', '.recipe-order-missing-button', function(e)
{
var objectName = $(e.currentTarget).attr('data-recipe-name');
var objectId = $(e.currentTarget).attr('data-recipe-id');
var button = $(this);
bootbox.confirm({
message: __t('Are you sure to put all missing ingredients for recipe "%s" on the shopping list?', objectName),
buttons: {
confirm: {
label: __t('Yes'),
className: 'btn-success'
},
cancel: {
label: __t('No'),
className: 'btn-danger'
}
},
callback: function(result)
{
if (result === true)
{
Grocy.FrontendHelpers.BeginUiBusy();
Grocy.Api.Post('recipes/' + objectId + '/add-not-fulfilled-products-to-shoppinglist', { },
function(result)
{
if (button.attr("data-recipe-type") == "normal")
{
button.addClass("disabled");
Grocy.FrontendHelpers.EndUiBusy();
}
else
{
window.location.reload();
}
},
function(xhr)
{
Grocy.FrontendHelpers.EndUiBusy();
console.error(xhr);
}
);
}
}
});
});

View File

@ -6,6 +6,10 @@ use \Grocy\Services\StockService;
class RecipesService extends BaseService class RecipesService extends BaseService
{ {
const RECIPE_TYPE_NORMAL = 'normal';
const RECIPE_TYPE_MEALPLAN_DAY = 'mealplan-day';
const RECIPE_TYPE_MEALPLAN_WEEK = 'mealplan-week';
public function __construct() public function __construct()
{ {
parent::__construct(); parent::__construct();

View File

@ -16,6 +16,8 @@
@section('content') @section('content')
<script> <script>
var fullcalendarEventSources = {!! json_encode(array($fullcalendarEventSources)) !!} var fullcalendarEventSources = {!! json_encode(array($fullcalendarEventSources)) !!}
var internalRecipes = {!! json_encode($internalRecipes) !!}
var recipesResolved = {!! json_encode($recipesResolved) !!}
</script> </script>
<div class="row"> <div class="row">
@ -43,7 +45,16 @@
@include('components.recipepicker', array( @include('components.recipepicker', array(
'recipes' => $recipes, 'recipes' => $recipes,
'isRequired' => true 'isRequired' => true,
'nextInputSelector' => '#servings'
))
@include('components.numberpicker', array(
'id' => 'servings',
'label' => 'Servings',
'min' => 1,
'value' => '1',
'invalidFeedback' => $__t('This cannot be lower than %s', '1')
)) ))
<input type="hidden" id="day" name="day" value=""> <input type="hidden" id="day" name="day" value="">