mirror of
https://github.com/grocy/grocy.git
synced 2025-04-29 17:45:39 +00:00
Finished first version of meal planning (for now closes #146)
This commit is contained in:
parent
3f53919ddd
commit
8504eb9b38
@ -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
|
||||||
|
@ -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()
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
54
migrations/0071.sql
Normal 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;
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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()
|
||||||
{
|
{
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -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();
|
||||||
|
@ -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="">
|
||||||
|
Loading…
x
Reference in New Issue
Block a user