diff --git a/changelog/62_UNRELEASED_xxxx-xx-xx.md b/changelog/62_UNRELEASED_xxxx-xx-xx.md index b7c4f867..0edbdc9f 100644 --- a/changelog/62_UNRELEASED_xxxx-xx-xx.md +++ b/changelog/62_UNRELEASED_xxxx-xx-xx.md @@ -2,7 +2,8 @@ > ⚠️ PHP 8 is now supported and from now on the only tested runtime version (although currently PHP 7.2 should still work). -### New feature: (Own) Product/stock entry/chores/batteries labels/barcodes ("grocycode") +### New feature: grocycode +#### (Own) Product/stock entry/chores/batteries labels/barcodes - Print own labels/barcodes for products/stock entries/chores/batteries and then scan that code on every place a product/stock entry/chore/battery can be selected - Can be printed (or downloaded) via - The product/chore/battery edit page @@ -19,6 +20,11 @@ - https://github.com/grocy/grocy/blob/master/docs/label-printing.md - (Thanks a lot @mistressofjellyfish for the initial work on this) +### New feature: Meal plan sections +- Split the meal plan into sections like Breakfast/Lunch/Dinner + - => New button "Configure sections" on the meal plan page to configure the sections (top right corner) + - => Each meal plan entry can be assigned to a section + ### New feature: Shopping list thermal printer support - The shopping list can now be printed on a thermal printer - The printer must be compatible to the `ESC/POS` protocol and needs to be locally attached or network reachable to/by the machine hosting grocy (so the server) diff --git a/controllers/RecipesController.php b/controllers/RecipesController.php index 5e6ce33a..af8e99ae 100644 --- a/controllers/RecipesController.php +++ b/controllers/RecipesController.php @@ -8,16 +8,15 @@ class RecipesController extends BaseController { public function MealPlan(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) { - // Load +- 8 days to always include week boundaries - + // Given date is always the first day of the week => load the coming week / 7 days if (isset($request->getQueryParams()['week']) && IsIsoDate($request->getQueryParams()['week'])) { $week = $request->getQueryParams()['week']; - $mealPlanWhereTimespan = "day BETWEEN DATE('$week', '-8 day') AND DATE('$week', '+8 day')"; + $mealPlanWhereTimespan = "day BETWEEN DATE('$week') AND DATE('$week', '+7 days')"; } else { - $mealPlanWhereTimespan = "day BETWEEN DATE(DATE('now', 'localtime'), '-8 day') AND DATE(DATE('now', 'localtime'), '+8 day')"; + $mealPlanWhereTimespan = "day BETWEEN DATE('now', 'localtime', 'weekday 0', '-7 days') AND DATE(DATE('now', 'localtime', 'weekday 0', '-7 days'), '+7 days')"; } $recipes = $this->getDatabase()->recipes()->where('type', RecipesService::RECIPE_TYPE_NORMAL)->fetchAll(); @@ -58,7 +57,9 @@ class RecipesController extends BaseController 'recipesResolved' => $this->getRecipesService()->GetRecipesResolved2("recipe_id IN (SELECT recipe_id FROM meal_plan_internal_recipe_relation WHERE $mealPlanWhereTimespan)"), 'products' => $this->getDatabase()->products()->orderBy('name', 'COLLATE NOCASE'), 'quantityUnits' => $this->getDatabase()->quantity_units()->orderBy('name', 'COLLATE NOCASE'), - 'quantityUnitConversionsResolved' => $this->getDatabase()->quantity_unit_conversions_resolved() + 'quantityUnitConversionsResolved' => $this->getDatabase()->quantity_unit_conversions_resolved(), + 'mealplanSections' => $this->getDatabase()->meal_plan_sections()->orderBy('sort_number'), + 'usedMealplanSections' => $this->getDatabase()->meal_plan_sections()->where("id IN (SELECT section_id FROM meal_plan WHERE $mealPlanWhereTimespan)")->orderBy('sort_number') ]); } @@ -190,6 +191,30 @@ class RecipesController extends BaseController return $this->renderPage($response, 'recipessettings'); } + public function MealPlanSectionEditForm(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) + { + if ($args['sectionId'] == 'new') + { + return $this->renderPage($response, 'mealplansectionform', [ + 'mode' => 'create' + ]); + } + else + { + return $this->renderPage($response, 'mealplansectionform', [ + 'mealplanSection' => $this->getDatabase()->meal_plan_sections($args['sectionId']), + 'mode' => 'edit' + ]); + } + } + + public function MealPlanSectionsList(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) + { + return $this->renderPage($response, 'mealplansections', [ + 'mealplanSections' => $this->getDatabase()->meal_plan_sections()->where('id > 0')->orderBy('sort_number') + ]); + } + public function __construct(\DI\Container $container) { parent::__construct($container); diff --git a/grocy.openapi.json b/grocy.openapi.json index e94b0cb3..e3325477 100644 --- a/grocy.openapi.json +++ b/grocy.openapi.json @@ -5579,7 +5579,8 @@ "stock_log", "stock", "stock_current_locations", - "chores_log" + "chores_log", + "meal_plan_sections" ] }, "ExposedEntityNoListing": { diff --git a/localization/demo_data.pot b/localization/demo_data.pot index 8c5618cd..3c4ef745 100644 --- a/localization/demo_data.pot +++ b/localization/demo_data.pot @@ -377,3 +377,12 @@ msgstr "" msgid "Finnish" msgstr "" + +msgid "Breakfast" +msgstr "" + +msgid "Lunch" +msgstr "" + +msgid "Dinner" +msgstr "" diff --git a/localization/strings.pot b/localization/strings.pot index aee0d9db..23a70624 100644 --- a/localization/strings.pot +++ b/localization/strings.pot @@ -2223,3 +2223,24 @@ msgstr "" msgid "Stock entry" msgstr "" + +msgid "Configure sections" +msgstr "" + +msgid "Meal plan sections" +msgstr "" + +msgid "Create meal plan section" +msgstr "" + +msgid "Sections will be ordered by that number on the meal plan" +msgstr "" + +msgid "Edit meal plan section" +msgstr "" + +msgid "Are you sure to delete meal plan section \"%s\"?" +msgstr "" + +msgid "Section" +msgstr "" diff --git a/migrations/0139.sql b/migrations/0139.sql index b180a395..a88f61ac 100644 --- a/migrations/0139.sql +++ b/migrations/0139.sql @@ -69,7 +69,7 @@ BEGIN GROUP BY product_id, product_qu_id; -- Create a shadow recipe per meal plan recipe - INSERT OR REPLACE INTO recipes + INSERT INTO recipes (id, name, type) SELECT (SELECT MIN(id) - 1 FROM recipes), CAST(NEW.day AS TEXT) || '#' || CAST(id AS TEXT), 'mealplan-shadow' FROM meal_plan @@ -77,9 +77,6 @@ BEGIN AND type = 'recipe' AND recipe_id IS NOT NULL; - DELETE FROM recipes_nestings - WHERE recipe_id IN (SELECT id FROM recipes WHERE name IN (SELECT CAST(NEW.day AS TEXT) || '#' || CAST(id AS TEXT) FROM meal_plan WHERE day = NEW.day) AND type = 'mealplan-shadow'); - INSERT INTO recipes_nestings (recipe_id, includes_recipe_id, servings) SELECT (SELECT id FROM recipes WHERE name = CAST(NEW.day AS TEXT) || '#' || CAST(meal_plan.id AS TEXT) AND type = 'mealplan-shadow'), recipe_id, recipe_servings @@ -190,7 +187,7 @@ BEGIN */ -- Create a shadow recipe per meal plan recipe - INSERT OR REPLACE INTO recipes + INSERT INTO recipes (id, name, type) SELECT (SELECT MIN(id) - 1 FROM recipes), CAST(NEW.day AS TEXT) || '#' || CAST(id AS TEXT), 'mealplan-shadow' FROM meal_plan @@ -292,7 +289,14 @@ BEGIN GROUP BY product_id, product_qu_id; -- Create a shadow recipe per meal plan recipe - INSERT OR REPLACE INTO recipes + DELETE FROM recipes_nestings + WHERE recipe_id IN (SELECT id FROM recipes WHERE name IN (SELECT CAST(NEW.day AS TEXT) || '#' || CAST(NEW.id AS TEXT) FROM meal_plan WHERE day = NEW.day) AND type = 'mealplan-shadow'); + + DELETE FROM recipes + WHERE type = 'mealplan-shadow' + AND name = CAST(NEW.day AS TEXT) || '#' || CAST(NEW.id AS TEXT); + + INSERT INTO recipes (id, name, type) SELECT (SELECT MIN(id) - 1 FROM recipes), CAST(NEW.day AS TEXT) || '#' || CAST(id AS TEXT), 'mealplan-shadow' FROM meal_plan @@ -300,9 +304,6 @@ BEGIN AND type = 'recipe' AND recipe_id IS NOT NULL; - DELETE FROM recipes_nestings - WHERE recipe_id IN (SELECT id FROM recipes WHERE name IN (SELECT CAST(NEW.day AS TEXT) || '#' || CAST(id AS TEXT) FROM meal_plan WHERE day = NEW.day) AND type = 'mealplan-shadow'); - INSERT INTO recipes_nestings (recipe_id, includes_recipe_id, servings) SELECT (SELECT id FROM recipes WHERE name = CAST(NEW.day AS TEXT) || '#' || CAST(meal_plan.id AS TEXT) AND type = 'mealplan-shadow'), recipe_id, recipe_servings diff --git a/migrations/0149.sql b/migrations/0149.sql new file mode 100644 index 00000000..cade364f --- /dev/null +++ b/migrations/0149.sql @@ -0,0 +1,14 @@ +CREATE TABLE meal_plan_sections ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE, + name TEXT NOT NULL UNIQUE, + sort_number INTEGER, + row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime')) +); + +INSERT INTO meal_plan_sections + (id, name, sort_number) +VALUES + (-1, '', -1); + +ALTER TABLE meal_plan +ADD section_id INTEGER NOT NULL DEFAULT -1; diff --git a/public/js/grocy.js b/public/js/grocy.js index 4b7c119b..9b8d1593 100644 --- a/public/js/grocy.js +++ b/public/js/grocy.js @@ -1116,3 +1116,5 @@ $(document).on("click", ".change-table-columns-rowgroup-toggle", function() dataTable.draw(); }); + +$("#meal-plan-nav-link").attr("href", $("#meal-plan-nav-link").attr("href") + "?week=" + moment().startOf("week").format("YYYY-MM-DD")); diff --git a/public/viewjs/mealplan.js b/public/viewjs/mealplan.js index be5bb14f..3e2dee49 100644 --- a/public/viewjs/mealplan.js +++ b/public/viewjs/mealplan.js @@ -12,23 +12,50 @@ if (!Grocy.MealPlanFirstDayOfWeek.isEmpty()) firstDay = parseInt(Grocy.MealPlanFirstDayOfWeek); } -var calendar = $("#calendar").fullCalendar({ - "themeSystem": "bootstrap4", - "header": { +$(".calendar").each(function() +{ + var container = $(this); + var sectionId = container.attr("data-section-id"); + var sectionName = container.attr("data-section-name"); + var isPrimarySection = BoolVal(container.attr("data-primary-section")); + var isLastSection = BoolVal(container.attr("data-last-section")); + + var headerConfig = { "left": "title", "center": "", "right": "prev,today,next" - }, - "weekNumbers": false, - "eventLimit": false, - "eventSources": fullcalendarEventSources, - "defaultView": ($(window).width() < 768) ? "basicDay" : "basicWeek", - "firstDay": firstDay, - "height": "auto", - "defaultDate": GetUriParam("week"), - "viewRender": function(view) + }; + if (!isPrimarySection) { - $(".fc-day-header").prepend('\ + headerConfig = { + "left": "", + "center": "", + "right": "" + }; + } + + container.fullCalendar({ + "themeSystem": "bootstrap4", + "header": headerConfig, + "weekNumbers": false, + "eventLimit": false, + "eventSources": fullcalendarEventSources, + "defaultView": ($(window).width() < 768) ? "agendaDay" : "agendaWeek", + "allDayText": sectionName, + "minTime": "00:00:00", + "maxTime": "00:00:01", + "scrollTime": "00:00:00", + "firstDay": firstDay, + "height": "auto", + "defaultDate": GetUriParam("week"), + "viewRender": function(view) + { + if (!isPrimarySection) + { + return; + } + + $(".calendar[data-primary-section='true'] .fc-day-header").prepend('\
\ \ \ @@ -39,114 +66,119 @@ var calendar = $("#calendar").fullCalendar({
\ '); - var weekRecipeName = view.start.year().toString() + "-" + ((view.start.week() - 1).toString().padStart(2, "0")).toString(); - var weekRecipe = FindObjectInArrayByPropertyValue(internalRecipes, "name", weekRecipeName); - - var weekCosts = 0; - var weekRecipeOrderMissingButtonHtml = ""; - var weekRecipeConsumeButtonHtml = ""; - var weekCostsHtml = ""; - if (weekRecipe !== null) - { - if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING) - { - weekCosts = FindObjectInArrayByPropertyValue(recipesResolved, "recipe_id", weekRecipe.id).costs; - weekCostsHtml = __t("Week costs") + ': ' + weekCosts.toString() + " "; - } - - var weekRecipeOrderMissingButtonDisabledClasses = ""; - if (FindObjectInArrayByPropertyValue(recipesResolved, "recipe_id", weekRecipe.id).need_fulfilled_with_shopping_list == 1) - { - weekRecipeOrderMissingButtonDisabledClasses = "disabled"; - } - - var weekRecipeConsumeButtonDisabledClasses = ""; - if (FindObjectInArrayByPropertyValue(recipesResolved, "recipe_id", weekRecipe.id).need_fulfilled == 0 || weekCosts == 0) - { - weekRecipeConsumeButtonDisabledClasses = "disabled"; - } + var weekRecipeName = view.start.year().toString() + "-" + ((view.start.week() - 1).toString().padStart(2, "0")).toString(); + var weekRecipe = FindObjectInArrayByPropertyValue(internalRecipes, "name", weekRecipeName); + var weekCosts = 0; var weekRecipeOrderMissingButtonHtml = ""; - if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_SHOPPINGLIST) + var weekRecipeConsumeButtonHtml = ""; + var weekCostsHtml = ""; + if (weekRecipe !== null) { - weekRecipeOrderMissingButtonHtml = ''; + if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING) + { + weekCosts = FindObjectInArrayByPropertyValue(recipesResolved, "recipe_id", weekRecipe.id).costs; + weekCostsHtml = __t("Week costs") + ': ' + weekCosts.toString() + " "; + } + + var weekRecipeOrderMissingButtonDisabledClasses = ""; + if (FindObjectInArrayByPropertyValue(recipesResolved, "recipe_id", weekRecipe.id).need_fulfilled_with_shopping_list == 1) + { + weekRecipeOrderMissingButtonDisabledClasses = "disabled"; + } + + var weekRecipeConsumeButtonDisabledClasses = ""; + if (FindObjectInArrayByPropertyValue(recipesResolved, "recipe_id", weekRecipe.id).need_fulfilled == 0 || weekCosts == 0) + { + weekRecipeConsumeButtonDisabledClasses = "disabled"; + } + + var weekRecipeOrderMissingButtonHtml = ""; + if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_SHOPPINGLIST) + { + weekRecipeOrderMissingButtonHtml = ''; + } + + weekRecipeConsumeButtonHtml = '' } - - weekRecipeConsumeButtonHtml = '' - } - $(".fc-header-toolbar .fc-center").html("

" + weekCostsHtml + weekRecipeOrderMissingButtonHtml + weekRecipeConsumeButtonHtml + "

"); - }, - "eventRender": function(event, element) - { - element.removeClass("fc-event"); - element.addClass("text-center"); - element.attr("data-meal-plan-entry", event.mealPlanEntry); - - var mealPlanEntry = JSON.parse(event.mealPlanEntry); - - var additionalTitleCssClasses = ""; - var doneButtonHtml = ''; - if (BoolVal(mealPlanEntry.done)) + $(".calendar[data-primary-section='true'] .fc-header-toolbar .fc-center").html("

" + weekCostsHtml + weekRecipeOrderMissingButtonHtml + weekRecipeConsumeButtonHtml + "

"); + }, + "eventRender": function(event, element) { - additionalTitleCssClasses = "text-strike-through text-muted"; - doneButtonHtml = ''; - } + element.removeClass("fc-event"); + element.addClass("text-center"); + element.attr("data-meal-plan-entry", event.mealPlanEntry); - if (event.type == "recipe") - { - var recipe = JSON.parse(event.recipe); - if (recipe === null || recipe === undefined) + var mealPlanEntry = JSON.parse(event.mealPlanEntry); + + if (sectionId != mealPlanEntry.section_id) { return false; } - var internalShadowRecipe = FindObjectInArrayByPropertyValue(internalRecipes, "name", mealPlanEntry.day + "#" + mealPlanEntry.id); - var resolvedRecipe = FindObjectInArrayByPropertyValue(recipesResolved, "recipe_id", internalShadowRecipe.id); - - element.attr("data-recipe", event.recipe); - - var recipeOrderMissingButtonDisabledClasses = ""; - if (resolvedRecipe.need_fulfilled_with_shopping_list == 1) + var additionalTitleCssClasses = ""; + var doneButtonHtml = ''; + if (BoolVal(mealPlanEntry.done)) { - recipeOrderMissingButtonDisabledClasses = "disabled"; + additionalTitleCssClasses = "text-strike-through text-muted"; + doneButtonHtml = ''; } - var recipeConsumeButtonDisabledClasses = ""; - if (resolvedRecipe.need_fulfilled == 0) + if (event.type == "recipe") { - recipeConsumeButtonDisabledClasses = "disabled"; - } + var recipe = JSON.parse(event.recipe); + if (recipe === null || recipe === undefined) + { + return false; + } - var fulfillmentInfoHtml = __t('Enough in stock'); - var fulfillmentIconHtml = ''; - if (resolvedRecipe.need_fulfilled != 1) - { - fulfillmentInfoHtml = __t('Not enough in stock'); - var fulfillmentIconHtml = ''; - } - var costsAndCaloriesPerServing = "" - if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING) - { - costsAndCaloriesPerServing = '
' + resolvedRecipe.costs + ' / ' + resolvedRecipe.calories + ' kcal ' + __t('per serving') + '
'; - } - else - { - costsAndCaloriesPerServing = '
' + resolvedRecipe.calories + ' kcal ' + __t('per serving') + '
'; - } + var internalShadowRecipe = FindObjectInArrayByPropertyValue(internalRecipes, "name", mealPlanEntry.day + "#" + mealPlanEntry.id); + var resolvedRecipe = FindObjectInArrayByPropertyValue(recipesResolved, "recipe_id", internalShadowRecipe.id); - if (!Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK) - { - fulfillmentIconHtml = ""; - fulfillmentInfoHtml = ""; - } + element.attr("data-recipe", event.recipe); - var shoppingListButtonHtml = ""; - if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_SHOPPINGLIST) - { - shoppingListButtonHtml = ''; - } + var recipeOrderMissingButtonDisabledClasses = ""; + if (resolvedRecipe.need_fulfilled_with_shopping_list == 1) + { + recipeOrderMissingButtonDisabledClasses = "disabled"; + } - element.html('\ + var recipeConsumeButtonDisabledClasses = ""; + if (resolvedRecipe.need_fulfilled == 0) + { + recipeConsumeButtonDisabledClasses = "disabled"; + } + + var fulfillmentInfoHtml = __t('Enough in stock'); + var fulfillmentIconHtml = ''; + if (resolvedRecipe.need_fulfilled != 1) + { + fulfillmentInfoHtml = __t('Not enough in stock'); + var fulfillmentIconHtml = ''; + } + var costsAndCaloriesPerServing = "" + if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING) + { + costsAndCaloriesPerServing = '
' + resolvedRecipe.costs + ' / ' + resolvedRecipe.calories + ' kcal ' + __t('per serving') + '
'; + } + else + { + costsAndCaloriesPerServing = '
' + resolvedRecipe.calories + ' kcal ' + __t('per serving') + '
'; + } + + if (!Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK) + { + fulfillmentIconHtml = ""; + fulfillmentInfoHtml = ""; + } + + var shoppingListButtonHtml = ""; + if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_SHOPPINGLIST) + { + shoppingListButtonHtml = ''; + } + + element.html('\
\ \
' + __n(mealPlanEntry.recipe_servings, "%s serving", "%s servings") + '
\ @@ -161,84 +193,63 @@ var calendar = $("#calendar").fullCalendar({ \
'); - if (recipe.picture_file_name && !recipe.picture_file_name.isEmpty()) - { - element.prepend('
') - } - - var dayRecipeName = event.start.format("YYYY-MM-DD"); - if (!$("#day-summary-" + dayRecipeName).length) // This runs for every event/recipe, so maybe multiple times per day, so only add the day summary once - { - var dayRecipe = FindObjectInArrayByPropertyValue(internalRecipes, "name", dayRecipeName); - if (dayRecipe != null) + if (recipe.picture_file_name && !recipe.picture_file_name.isEmpty()) { - var dayRecipeResolved = FindObjectInArrayByPropertyValue(recipesResolved, "recipe_id", dayRecipe.id); - - var costsAndCaloriesPerDay = "" - if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING) - { - costsAndCaloriesPerDay = '
' + dayRecipeResolved.costs + ' / ' + dayRecipeResolved.calories + ' kcal ' + __t('per day') + '
'; - } - else - { - costsAndCaloriesPerDay = '
' + dayRecipeResolved.calories + ' kcal ' + __t('per day') + '
'; - } - $(".fc-day-header[data-date='" + dayRecipeName + "']").append('
' + costsAndCaloriesPerDay + '
'); + element.prepend('
') } } - } - if (event.type == "product") - { - var productDetails = JSON.parse(event.productDetails); - if (productDetails === null || productDetails === undefined) + else if (event.type == "product") { - return false; - } + var productDetails = JSON.parse(event.productDetails); + if (productDetails === null || productDetails === undefined) + { + return false; + } - if (productDetails.last_price === null) - { - productDetails.last_price = 0; - } + if (productDetails.last_price === null) + { + productDetails.last_price = 0; + } - element.attr("data-product-details", event.productDetails); + element.attr("data-product-details", event.productDetails); - var productOrderMissingButtonDisabledClasses = "disabled"; - if (parseFloat(productDetails.stock_amount_aggregated) < parseFloat(mealPlanEntry.product_amount)) - { - productOrderMissingButtonDisabledClasses = ""; - } + var productOrderMissingButtonDisabledClasses = "disabled"; + if (parseFloat(productDetails.stock_amount_aggregated) < parseFloat(mealPlanEntry.product_amount)) + { + productOrderMissingButtonDisabledClasses = ""; + } - var productConsumeButtonDisabledClasses = "disabled"; - if (parseFloat(productDetails.stock_amount_aggregated) >= parseFloat(mealPlanEntry.product_amount)) - { - productConsumeButtonDisabledClasses = ""; - } + var productConsumeButtonDisabledClasses = "disabled"; + if (parseFloat(productDetails.stock_amount_aggregated) >= parseFloat(mealPlanEntry.product_amount)) + { + productConsumeButtonDisabledClasses = ""; + } - fulfillmentInfoHtml = __t('Not enough in stock'); - var fulfillmentIconHtml = ''; - if (parseFloat(productDetails.stock_amount_aggregated) >= parseFloat(mealPlanEntry.product_amount)) - { - var fulfillmentInfoHtml = __t('Enough in stock'); - var fulfillmentIconHtml = ''; - } + fulfillmentInfoHtml = __t('Not enough in stock'); + var fulfillmentIconHtml = ''; + if (parseFloat(productDetails.stock_amount_aggregated) >= parseFloat(mealPlanEntry.product_amount)) + { + var fulfillmentInfoHtml = __t('Enough in stock'); + var fulfillmentIconHtml = ''; + } - var costsAndCaloriesPerServing = "" - if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING) - { - costsAndCaloriesPerServing = '
' + productDetails.last_price * mealPlanEntry.product_amount + ' / ' + productDetails.product.calories * mealPlanEntry.product_amount + ' kcal ' + '
'; - } - else - { - costsAndCaloriesPerServing = '
' + productDetails.product.calories * mealPlanEntry.product_amount + ' kcal ' + '
'; - } + var costsAndCaloriesPerServing = "" + if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING) + { + costsAndCaloriesPerServing = '
' + productDetails.last_price * mealPlanEntry.product_amount + ' / ' + productDetails.product.calories * mealPlanEntry.product_amount + ' kcal ' + '
'; + } + else + { + costsAndCaloriesPerServing = '
' + productDetails.product.calories * mealPlanEntry.product_amount + ' kcal ' + '
'; + } - var shoppingListButtonHtml = ""; - if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_SHOPPINGLIST) - { - shoppingListButtonHtml = ''; - } + var shoppingListButtonHtml = ""; + if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_SHOPPINGLIST) + { + shoppingListButtonHtml = ''; + } - element.html('\ + element.html('\
\ \
' + mealPlanEntry.product_amount + " " + __n(mealPlanEntry.product_amount, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural) + '
\ @@ -253,9 +264,22 @@ var calendar = $("#calendar").fullCalendar({ \
'); - if (productDetails.product.picture_file_name && !productDetails.product.picture_file_name.isEmpty()) + if (productDetails.product.picture_file_name && !productDetails.product.picture_file_name.isEmpty()) + { + element.prepend('
') + } + } + else if (event.type == "note") { - element.prepend('
') + element.html('\ +
\ +
' + mealPlanEntry.note + '
\ +
\ + \ + \ + ' + doneButtonHtml + ' \ +
\ +
'); } var dayRecipeName = event.start.format("YYYY-MM-DD"); @@ -275,47 +299,48 @@ var calendar = $("#calendar").fullCalendar({ { costsAndCaloriesPerDay = '
' + dayRecipeResolved.calories + ' kcal ' + __t('per day') + '
'; } - $(".fc-day-header[data-date='" + dayRecipeName + "']").append('
' + costsAndCaloriesPerDay + '
'); + + $(".calendar[data-primary-section='true'] .fc-day-header[data-date='" + dayRecipeName + "']").append('
' + costsAndCaloriesPerDay + '
'); + } + } + }, + "eventAfterAllRender": function(view) + { + if (isPrimarySection) + { + UpdateUriParam("week", view.start.format("YYYY-MM-DD")); + + if (firstRender) + { + firstRender = false + } + else + { + $(".calendar").addClass("d-none"); + window.location.reload(); + return false; + } + } + + if (isLastSection) + { + $(".fc-axis span").replaceWith(function() + { + return $("
", { html: $(this).html() }); + }); + + RefreshLocaleNumberDisplay(); + LoadImagesLazy(); + $('[data-toggle="tooltip"]').tooltip(); + + if (!Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK) + { + $(".recipe-order-missing-button").addClass("d-none"); + $(".recipe-consume-button").addClass("d-none"); } } } - else if (event.type == "note") - { - element.html('\ -
\ -
' + mealPlanEntry.note + '
\ -
\ - \ - \ - ' + doneButtonHtml + ' \ -
\ -
'); - } - }, - "eventAfterAllRender": function(view) - { - UpdateUriParam("week", view.start.format("YYYY-MM-DD")); - - if (firstRender) - { - firstRender = false - } - else - { - window.location.reload(); - return false; - } - - RefreshLocaleNumberDisplay(); - LoadImagesLazy(); - $('[data-toggle="tooltip"]').tooltip(); - - if (!Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK) - { - $(".recipe-order-missing-button").addClass("d-none"); - $(".recipe-consume-button").addClass("d-none"); - } - }, + }); }); $(document).on("click", ".add-recipe-button", function(e) @@ -325,6 +350,7 @@ $(document).on("click", ".add-recipe-button", function(e) $("#add-recipe-modal-title").text(__t("Add recipe on %s", day.toString())); $("#day").val(day.toString()); Grocy.Components.RecipePicker.Clear(); + $("#section_id_note").val(-1); $("#add-recipe-modal").modal("show"); Grocy.FrontendHelpers.ValidateForm("add-recipe-form"); Grocy.IsMealPlanEntryEditAction = false; @@ -337,6 +363,7 @@ $(document).on("click", ".add-note-button", function(e) $("#add-note-modal-title").text(__t("Add note on %s", day.toString())); $("#day").val(day.toString()); $("#note").val(""); + $("#section_id_note").val(-1); $("#add-note-modal").modal("show"); Grocy.FrontendHelpers.ValidateForm("add-note-form"); Grocy.IsMealPlanEntryEditAction = false; @@ -349,6 +376,7 @@ $(document).on("click", ".add-product-button", function(e) $("#add-product-modal-title").text(__t("Add product on %s", day.toString())); $("#day").val(day.toString()); Grocy.Components.ProductPicker.Clear(); + $("#section_id_note").val(-1); $("#add-product-modal").modal("show"); Grocy.FrontendHelpers.ValidateForm("add-product-form"); Grocy.IsMealPlanEntryEditAction = false; @@ -365,6 +393,7 @@ $(document).on("click", ".edit-meal-plan-entry-button", function(e) $("#recipe_servings").val(mealPlanEntry.recipe_servings); Grocy.Components.RecipePicker.SetId(mealPlanEntry.recipe_id); $("#add-recipe-modal").modal("show"); + $("#section_id_recipe").val(mealPlanEntry.section_id); Grocy.FrontendHelpers.ValidateForm("add-recipe-form"); } else if (mealPlanEntry.type == "product") @@ -373,6 +402,7 @@ $(document).on("click", ".edit-meal-plan-entry-button", function(e) $("#day").val(mealPlanEntry.day.toString()); Grocy.Components.ProductPicker.SetId(mealPlanEntry.product_id); $("#add-product-modal").modal("show"); + $("#section_id_product").val(mealPlanEntry.section_id); Grocy.FrontendHelpers.ValidateForm("add-product-form"); Grocy.Components.ProductPicker.GetPicker().trigger("change"); } @@ -382,6 +412,7 @@ $(document).on("click", ".edit-meal-plan-entry-button", function(e) $("#day").val(mealPlanEntry.day.toString()); $("#note").val(mealPlanEntry.note); $("#add-note-modal").modal("show"); + $("#section_id_note").val(mealPlanEntry.section_id); Grocy.FrontendHelpers.ValidateForm("add-note-form"); } Grocy.IsMealPlanEntryEditAction = true; @@ -450,9 +481,13 @@ $('#save-add-recipe-button').on('click', function(e) return false; } + var formData = $('#add-recipe-form').serializeJSON(); + formData.section_id = formData.section_id_recipe; + delete formData.section_id_recipe; + if (Grocy.IsMealPlanEntryEditAction) { - Grocy.Api.Put('objects/meal_plan/' + Grocy.MealPlanEntryEditObjectId.toString(), $('#add-recipe-form').serializeJSON(), + Grocy.Api.Put('objects/meal_plan/' + Grocy.MealPlanEntryEditObjectId.toString(), formData, function(result) { window.location.reload(); @@ -465,7 +500,7 @@ $('#save-add-recipe-button').on('click', function(e) } else { - Grocy.Api.Post('objects/meal_plan', $('#add-recipe-form').serializeJSON(), + Grocy.Api.Post('objects/meal_plan', formData, function(result) { window.location.reload(); @@ -494,6 +529,8 @@ $('#save-add-note-button').on('click', function(e) var jsonData = $('#add-note-form').serializeJSON(); jsonData.day = $("#day").val(); + jsonData.section_id = formData.section_id_note; + delete formData.section_id_note; if (Grocy.IsMealPlanEntryEditAction) { @@ -545,6 +582,8 @@ $('#save-add-product-button').on('click', function(e) delete jsonData.amount; jsonData.product_qu_id = $("#qu_id").val();; delete jsonData.qu_id; + jsonData.section_id = jsonData.section_id_product; + delete jsonData.section_id_product; if (Grocy.IsMealPlanEntryEditAction) { @@ -939,16 +978,19 @@ $(document).on("click", ".mealplan-entry-undone-button", function(e) $(window).one("resize", function() { - // Automatically switch the calendar to "basicDay" view on small screens - // and to "basicWeek" otherwise - if ($(window).width() < 768) + // Automatically switch the calendar to "agendaDay" view on small screens and to "agendaWeek" otherwise + var windowWidth = $(window).width(); + $(".calendar").each(function() { - calendar.fullCalendar("changeView", "basicDay"); - } - else - { - calendar.fullCalendar("changeView", "basicWeek"); - } + if (windowWidth < 768) + { + $(this).fullCalendar("changeView", "agendaDay"); + } + else + { + $(this).fullCalendar("changeView", "agendaWeek"); + } + }); }); Grocy.Components.ProductPicker.GetPicker().on('change', function(e) diff --git a/public/viewjs/mealplansectionform.js b/public/viewjs/mealplansectionform.js new file mode 100644 index 00000000..b926be4b --- /dev/null +++ b/public/viewjs/mealplansectionform.js @@ -0,0 +1,75 @@ +$('#save-mealplansection-button').on('click', function(e) +{ + e.preventDefault(); + + var jsonData = $('#mealplansection-form').serializeJSON(); + Grocy.FrontendHelpers.BeginUiBusy("mealplansection-form"); + + if (Grocy.EditMode === 'create') + { + Grocy.Api.Post('objects/meal_plan_sections', jsonData, + function(result) + { + if (GetUriParam("embedded") !== undefined) + { + window.parent.postMessage(WindowMessageBag("Reload"), Grocy.BaseUrl); + } + else + { + window.location.href = U('/mealplansections'); + } + }, + function(xhr) + { + Grocy.FrontendHelpers.EndUiBusy("mealplansection-form"); + Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response) + } + ); + } + else + { + Grocy.Api.Put('objects/meal_plan_sections/' + Grocy.EditObjectId, jsonData, + function(result) + { + if (GetUriParam("embedded") !== undefined) + { + window.parent.postMessage(WindowMessageBag("Reload"), Grocy.BaseUrl); + } + else + { + window.location.href = U('/mealplansections'); + } + }, + function(xhr) + { + Grocy.FrontendHelpers.EndUiBusy("mealplansection-form"); + Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response) + } + ); + } +}); + +$('#mealplansection-form input').keyup(function(event) +{ + Grocy.FrontendHelpers.ValidateForm('mealplansection-form'); +}); + +$('#mealplansection-form input').keydown(function(event) +{ + if (event.keyCode === 13) //Enter + { + event.preventDefault(); + + if (document.getElementById('mealplansection-form').checkValidity() === false) //There is at least one validation error + { + return false; + } + else + { + $('#save-mealplansections-button').click(); + } + } +}); + +Grocy.FrontendHelpers.ValidateForm('mealplansection-form'); +$('#name').focus(); diff --git a/public/viewjs/mealplansections.js b/public/viewjs/mealplansections.js new file mode 100644 index 00000000..410f89cf --- /dev/null +++ b/public/viewjs/mealplansections.js @@ -0,0 +1,63 @@ +var mealplanSectionsTable = $('#mealplansections-table').DataTable({ + 'order': [[2, 'asc']], + 'columnDefs': [ + { 'orderable': false, 'targets': 0 }, + { 'searchable': false, "targets": 0 } + ].concat($.fn.dataTable.defaults.columnDefs) +}); +$('#mealplansections-table tbody').removeClass("d-none"); +mealplanSectionsTable.columns.adjust().draw(); + +$("#search").on("keyup", Delay(function() +{ + var value = $(this).val(); + if (value === "all") + { + value = ""; + } + + mealplanSectionsTable.search(value).draw(); +}, 200)); + +$("#clear-filter-button").on("click", function() +{ + $("#search").val(""); + mealplanSectionsTable.search("").draw(); +}); + +$(document).on('click', '.mealplansection-delete-button', function(e) +{ + var objectName = $(e.currentTarget).attr('data-mealplansection-name'); + var objectId = $(e.currentTarget).attr('data-mealplansection-id'); + + bootbox.confirm({ + message: __t('Are you sure to delete meal plan section "%s"?', objectName), + closeButton: false, + buttons: { + confirm: { + label: __t('Yes'), + className: 'btn-success' + }, + cancel: { + label: __t('No'), + className: 'btn-danger' + } + }, + callback: function(result) + { + if (result === true) + { + Grocy.Api.Delete('objects/meal_plan_sections/' + objectId, {}, + function(result) + { + window.location.href = U('/mealplansections'); + }, + function(xhr) + { + console.error(xhr); + } + ); + } + } + }); +}); diff --git a/routes.php b/routes.php index fc0dd152..85bf50cf 100644 --- a/routes.php +++ b/routes.php @@ -87,6 +87,8 @@ $app->group('', function (RouteCollectorProxy $group) { $group->get('/recipe/{recipeId}', '\Grocy\Controllers\RecipesController:RecipeEditForm'); $group->get('/recipe/{recipeId}/pos/{recipePosId}', '\Grocy\Controllers\RecipesController:RecipePosEditForm'); $group->get('/mealplan', '\Grocy\Controllers\RecipesController:MealPlan'); + $group->get('/mealplansections', '\Grocy\Controllers\RecipesController:MealPlanSectionsList'); + $group->get('/mealplansection/{sectionId}', '\Grocy\Controllers\RecipesController:MealPlanSectionEditForm'); $group->get('/recipessettings', '\Grocy\Controllers\RecipesController:RecipesSettings'); } diff --git a/services/DemoDataGeneratorService.php b/services/DemoDataGeneratorService.php index d16454cf..d57552fc 100644 --- a/services/DemoDataGeneratorService.php +++ b/services/DemoDataGeneratorService.php @@ -140,15 +140,22 @@ class DemoDataGeneratorService extends BaseService INSERT INTO recipes_nestings(recipe_id, includes_recipe_id) VALUES (6, 4); INSERT INTO recipes_nestings(recipe_id, includes_recipe_id) VALUES (6, 5); - INSERT INTO meal_plan(day, recipe_id) VALUES ('{$mondayThisWeek}', 1); - INSERT INTO meal_plan(day, recipe_id) VALUES ('{$tuesdayThisWeek}', 2); - INSERT INTO meal_plan(day, recipe_id) VALUES ('{$wednesdayThisWeek}', 3); - INSERT INTO meal_plan(day, recipe_id) VALUES ('{$thursdayThisWeek}', 4); - INSERT INTO meal_plan(day, recipe_id) VALUES ('{$fridayThisWeek}', 1); - INSERT INTO meal_plan(day, recipe_id) VALUES ('{$saturdayThisWeek}', 2); - INSERT INTO meal_plan(day, recipe_id) VALUES ('{$sundayThisWeek}', 4); - INSERT INTO meal_plan(day, type, note) VALUES ('{$tuesdayThisWeek}', 'note', '{$this->__t_sql('This is a note')}'); - INSERT INTO meal_plan(day, type, product_id, product_amount) VALUES (DATE('{$mondayThisWeek}', '-1 days'), 'product', 3, 1); + INSERT INTO meal_plan_sections (name, sort_number) VALUES ('{$this->__t_sql('Breakfast')}', 10); + INSERT INTO meal_plan_sections (name, sort_number) VALUES ('{$this->__t_sql('Lunch')}', 20); + INSERT INTO meal_plan_sections (name, sort_number) VALUES ('{$this->__t_sql('Dinner')}', 30); + + INSERT INTO meal_plan(day, recipe_id, section_id) VALUES ('{$mondayThisWeek}', 1, 2); + INSERT INTO meal_plan(day, recipe_id, section_id) VALUES ('{$tuesdayThisWeek}', 2, 2); + INSERT INTO meal_plan(day, recipe_id, section_id) VALUES ('{$wednesdayThisWeek}', 3, 3); + INSERT INTO meal_plan(day, recipe_id, section_id) VALUES ('{$thursdayThisWeek}', 4, 1); + INSERT INTO meal_plan(day, recipe_id, section_id) VALUES ('{$fridayThisWeek}', 2, 2); + INSERT INTO meal_plan(day, recipe_id, section_id) VALUES ('{$saturdayThisWeek}', 1, 2); + INSERT INTO meal_plan(day, recipe_id, section_id) VALUES ('{$sundayThisWeek}', 4, 2); + INSERT INTO meal_plan(day, type, note, section_id) VALUES ('{$tuesdayThisWeek}', 'note', '{$this->__t_sql('This is a note')}', 1); + INSERT INTO meal_plan(day, type, product_id, product_amount, section_id) VALUES (DATE('{$mondayThisWeek}', '-1 days'), 'product', 3, 1, 3); + INSERT INTO meal_plan(day, type, product_id, product_amount, section_id) VALUES (DATE('{$tuesdayThisWeek}', '-1 days'), 'product', 9, 1, 1); + INSERT INTO meal_plan(day, type, product_id, product_amount, section_id) VALUES (DATE('{$thursdayThisWeek}', '-1 days'), 'product', 25, 1, 1); + INSERT INTO meal_plan(day, type, note, section_id) VALUES ('{$saturdayThisWeek}', 'note', '{$this->__t_sql('Some good snacks')}', 3); INSERT INTO chores (name, period_type, period_days) VALUES ('{$this->__t_sql('Changed towels in the bathroom')}', 'manually', 5); --1 INSERT INTO chores (name, period_type, period_days, assignment_type, assignment_config, next_execution_assigned_to_user_id) VALUES ('{$this->__t_sql('Cleaned the kitchen floor')}', 'dynamic-regular', 7, 'random', '1,2,3,4', 1); --2 diff --git a/views/layout/default.blade.php b/views/layout/default.blade.php index 9c940beb..f17f0fe5 100644 --- a/views/layout/default.blade.php +++ b/views/layout/default.blade.php @@ -188,7 +188,8 @@ data-placement="right" title="{{ $__t('Meal plan') }}" data-nav-for-page="mealplan"> - {{ $__t('Meal plan') }} diff --git a/views/mealplan.blade.php b/views/mealplan.blade.php index 4bf3d1cc..dd49853f 100644 --- a/views/mealplan.blade.php +++ b/views/mealplan.blade.php @@ -24,6 +24,39 @@ max-height: 140px; } + .fc-time-grid-container, + hr.fc-divider { + display: none; + } + + .fc-axis { + width: 25px !important; + } + + .fc-axis div { + transform: translateX(-50%) translateY(-50%) rotate(-90deg); + font-weight: bold; + font-size: 1.8em; + letter-spacing: 0.1em; + position: absolute; + top: 50%; + left: 0; + margin-left: 15px; + } + + .fc-content-skeleton { + padding-bottom: 0 !important; + } + + .calendar[data-primary-section='false'] .fc-toolbar.fc-header-toolbar, + .calendar[data-primary-section='false'] .fc-head { + display: none; + } + + .calendar[data-primary-section='false'] { + border-top: #d6d6d6 solid 5px; + } + @media (min-width: 400px) { .table-inline-menu.dropdown-menu { width: 200px !important; @@ -47,17 +80,56 @@

+@foreach($usedMealplanSections as $mealplanSection)
-
+
last doesn't work however, is always null... --}} + data-last-section="{{ BoolToString(array_values(array_slice($usedMealplanSections->fetchAll(), -1))[0]->id == $mealplanSection->id) }}"> +
+@endforeach + +{{-- Default empty calendar/section when no single meal plan entry is in the given date range --}} +@if($usedMealplanSections->count() === 0) +
+
+
+
+
+
+@endif +
+ + +
+ @@ -171,6 +267,18 @@ 'additionalGroupCssClasses' => 'mb-0' )) +
+ + +
+ diff --git a/views/mealplansectionform.blade.php b/views/mealplansectionform.blade.php new file mode 100644 index 00000000..31493961 --- /dev/null +++ b/views/mealplansectionform.blade.php @@ -0,0 +1,62 @@ +@extends('layout.default') + +@if($mode == 'edit') +@section('title', $__t('Edit meal plan section')) +@else +@section('title', $__t('Create meal plan section')) +@endif + +@section('viewJsName', 'mealplansectionform') + +@section('content') +
+
+

@yield('title')

+
+
+ +
+ +
+
+ + + @if($mode == 'edit') + + @endif + +
+ +
+ + +
{{ $__t('A name is required') }}
+
+ + @php if($mode == 'edit' && !empty($mealplanSection->sort_number)) { $value = $mealplanSection->sort_number; } else { $value = ''; } @endphp + @include('components.numberpicker', array( + 'id' => 'sort_number', + 'label' => 'Sort number', + 'min' => 0, + 'value' => $value, + 'isRequired' => false, + 'hint' => $__t('Sections will be ordered by that number on the meal plan') + )) + + + +
+
+
+@stop diff --git a/views/mealplansections.blade.php b/views/mealplansections.blade.php new file mode 100644 index 00000000..e9e87cc0 --- /dev/null +++ b/views/mealplansections.blade.php @@ -0,0 +1,111 @@ +@extends('layout.default') + +@section('title', $__t('Meal plan sections')) +@section('activeNav', 'mealplansections') +@section('viewJsName', 'mealplansections') + +@section('content') +
+
+ +
+
+ +
+ +
+
+
+
+ +
+ +
+
+ +
+ +
+
+ + + + + + + + + + @foreach($mealplanSections as $mealplanSection) + + + + + + @endforeach + +
+ {{ $__t('Name') }}{{ $__t('Sort number') }}
+ + + + + + + + {{ $mealplanSection->name }} + + {{ $mealplanSection->sort_number }} +
+
+
+@stop