mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	feat: add meal plan to shopping list (#2653)
* refactored recipe add to shopping list dialog * added context menu to meal plan day * cleaned up presentation * consolidate repeated recipes * added alerts * lint * lint v2 * fixed undefined recipeRef bug * lint * made scale + slug implementation less horrible
This commit is contained in:
		
							
								
								
									
										145
									
								
								frontend/components/Domain/Group/GroupMealPlanDayContextMenu.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								frontend/components/Domain/Group/GroupMealPlanDayContextMenu.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,145 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="text-center"> | ||||||
|  |     <RecipeDialogAddToShoppingList | ||||||
|  |       v-if="shoppingLists" | ||||||
|  |       v-model="shoppingListDialog" | ||||||
|  |       :recipes="recipesWithScales" | ||||||
|  |       :shopping-lists="shoppingLists" | ||||||
|  |     /> | ||||||
|  |     <v-menu | ||||||
|  |       offset-y | ||||||
|  |       left | ||||||
|  |       :bottom="!menuTop" | ||||||
|  |       :nudge-bottom="!menuTop ? '5' : '0'" | ||||||
|  |       :top="menuTop" | ||||||
|  |       :nudge-top="menuTop ? '5' : '0'" | ||||||
|  |       allow-overflow | ||||||
|  |       close-delay="125" | ||||||
|  |       :open-on-hover="$vuetify.breakpoint.mdAndUp" | ||||||
|  |       content-class="d-print-none" | ||||||
|  |     > | ||||||
|  |       <template #activator="{ on, attrs }"> | ||||||
|  |         <v-btn :fab="fab" :small="fab" :color="color" :icon="!fab" dark v-bind="attrs" v-on="on" @click.prevent> | ||||||
|  |           <v-icon>{{ icon }}</v-icon> | ||||||
|  |         </v-btn> | ||||||
|  |       </template> | ||||||
|  |       <v-list dense> | ||||||
|  |         <v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)"> | ||||||
|  |           <v-list-item-icon> | ||||||
|  |             <v-icon :color="item.color"> {{ item.icon }} </v-icon> | ||||||
|  |           </v-list-item-icon> | ||||||
|  |           <v-list-item-title>{{ item.title }}</v-list-item-title> | ||||||
|  |         </v-list-item> | ||||||
|  |       </v-list> | ||||||
|  |     </v-menu> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script lang="ts"> | ||||||
|  | import { computed, defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api"; | ||||||
|  | import { Recipe } from "~/lib/api/types/recipe"; | ||||||
|  | import RecipeDialogAddToShoppingList from "~/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue"; | ||||||
|  | import { ShoppingListSummary } from "~/lib/api/types/group"; | ||||||
|  | import { useUserApi } from "~/composables/api"; | ||||||
|  |  | ||||||
|  | export interface ContextMenuItem { | ||||||
|  |   title: string; | ||||||
|  |   icon: string; | ||||||
|  |   color: string | undefined; | ||||||
|  |   event: string; | ||||||
|  |   isPublic: boolean; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default defineComponent({ | ||||||
|  |   components: { | ||||||
|  |     RecipeDialogAddToShoppingList, | ||||||
|  |   }, | ||||||
|  |   props: { | ||||||
|  |     recipes: { | ||||||
|  |       type: Array as () => Recipe[], | ||||||
|  |       default: () => [], | ||||||
|  |     }, | ||||||
|  |     menuTop: { | ||||||
|  |       type: Boolean, | ||||||
|  |       default: true, | ||||||
|  |     }, | ||||||
|  |     fab: { | ||||||
|  |       type: Boolean, | ||||||
|  |       default: false, | ||||||
|  |     }, | ||||||
|  |     color: { | ||||||
|  |       type: String, | ||||||
|  |       default: "primary", | ||||||
|  |     }, | ||||||
|  |     menuIcon: { | ||||||
|  |       type: String, | ||||||
|  |       default: null, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   setup(props, context) { | ||||||
|  |     const { $globals, i18n } = useContext(); | ||||||
|  |     const api = useUserApi(); | ||||||
|  |  | ||||||
|  |     const state = reactive({ | ||||||
|  |       loading: false, | ||||||
|  |       shoppingListDialog: false, | ||||||
|  |       menuItems: [ | ||||||
|  |         { | ||||||
|  |           title: i18n.tc("recipe.add-to-list"), | ||||||
|  |           icon: $globals.icons.cartCheck, | ||||||
|  |           color: undefined, | ||||||
|  |           event: "shoppingList", | ||||||
|  |           isPublic: false, | ||||||
|  |         }, | ||||||
|  |       ], | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     const icon = props.menuIcon || $globals.icons.dotsVertical; | ||||||
|  |  | ||||||
|  |     const shoppingLists = ref<ShoppingListSummary[]>(); | ||||||
|  |     const recipesWithScales = computed(() => { | ||||||
|  |       return props.recipes.map((recipe) => { | ||||||
|  |         return { | ||||||
|  |           scale: 1, | ||||||
|  |           ...recipe, | ||||||
|  |         }; | ||||||
|  |       }) | ||||||
|  |     }) | ||||||
|  |  | ||||||
|  |     async function getShoppingLists() { | ||||||
|  |       const { data } = await api.shopping.lists.getAll(); | ||||||
|  |       if (data) { | ||||||
|  |         shoppingLists.value = data.items ?? []; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const eventHandlers: { [key: string]: () => void | Promise<any> } = { | ||||||
|  |       shoppingList: () => { | ||||||
|  |         getShoppingLists(); | ||||||
|  |         state.shoppingListDialog = true; | ||||||
|  |       }, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     function contextMenuEventHandler(eventKey: string) { | ||||||
|  |       const handler = eventHandlers[eventKey]; | ||||||
|  |  | ||||||
|  |       if (handler && typeof handler === "function") { | ||||||
|  |         handler(); | ||||||
|  |         state.loading = false; | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       context.emit(eventKey); | ||||||
|  |       state.loading = false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return { | ||||||
|  |       ...toRefs(state), | ||||||
|  |       contextMenuEventHandler, | ||||||
|  |       icon, | ||||||
|  |       recipesWithScales, | ||||||
|  |       shoppingLists, | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  | }) | ||||||
|  | </script> | ||||||
| @@ -69,77 +69,12 @@ | |||||||
|         ></v-select> |         ></v-select> | ||||||
|       </v-card-text> |       </v-card-text> | ||||||
|     </BaseDialog> |     </BaseDialog> | ||||||
|     <BaseDialog v-model="shoppingListDialog" :title="$t('recipe.add-to-list')" :icon="$globals.icons.cartCheck"> |     <RecipeDialogAddToShoppingList | ||||||
|       <v-card-text> |       v-if="shoppingLists && recipeRefWithScale" | ||||||
|         <v-card |       v-model="shoppingListDialog" | ||||||
|           v-for="list in shoppingLists" |       :recipes="[recipeRefWithScale]" | ||||||
|           :key="list.id" |       :shopping-lists="shoppingLists" | ||||||
|           hover |     /> | ||||||
|           class="my-2 left-border" |  | ||||||
|           @click="openShoppingListIngredientDialog(list)" |  | ||||||
|         > |  | ||||||
|           <v-card-title class="py-2"> |  | ||||||
|             {{ list.name }} |  | ||||||
|           </v-card-title> |  | ||||||
|         </v-card> |  | ||||||
|       </v-card-text> |  | ||||||
|     </BaseDialog> |  | ||||||
|     <BaseDialog |  | ||||||
|       v-model="shoppingListIngredientDialog" |  | ||||||
|       :title="selectedShoppingList ? selectedShoppingList.name : $t('recipe.add-to-list')" |  | ||||||
|       :icon="$globals.icons.cartCheck" |  | ||||||
|       width="70%" |  | ||||||
|       :submit-text="$tc('recipe.add-to-list')" |  | ||||||
|       @submit="addRecipeToList()" |  | ||||||
|     > |  | ||||||
|       <v-card |  | ||||||
|         elevation="0" |  | ||||||
|         height="fit-content" |  | ||||||
|         max-height="60vh" |  | ||||||
|         width="100%" |  | ||||||
|         :class="$vuetify.breakpoint.smAndDown ? '' : 'ingredient-grid'" |  | ||||||
|         :style="$vuetify.breakpoint.smAndDown ? '' : { gridTemplateRows: `repeat(${Math.ceil(recipeIngredients.length / 2)}, min-content)` }" |  | ||||||
|         style="overflow-y: auto" |  | ||||||
|       > |  | ||||||
|         <v-list-item |  | ||||||
|           v-for="(ingredientData, i) in recipeIngredients" |  | ||||||
|           :key="'ingredient' + i" |  | ||||||
|           dense |  | ||||||
|           @click="recipeIngredients[i].checked = !recipeIngredients[i].checked" |  | ||||||
|         > |  | ||||||
|           <v-checkbox |  | ||||||
|             hide-details |  | ||||||
|             :input-value="ingredientData.checked" |  | ||||||
|             class="pt-0 my-auto py-auto" |  | ||||||
|             color="secondary" |  | ||||||
|           /> |  | ||||||
|           <v-list-item-content :key="ingredientData.ingredient.quantity"> |  | ||||||
|             <RecipeIngredientListItem |  | ||||||
|               :ingredient="ingredientData.ingredient" |  | ||||||
|               :disable-amount="ingredientData.disableAmount" |  | ||||||
|               :scale="recipeScale" /> |  | ||||||
|           </v-list-item-content> |  | ||||||
|         </v-list-item> |  | ||||||
|       </v-card> |  | ||||||
|       <div class="d-flex justify-end mb-4 mt-2"> |  | ||||||
|         <BaseButtonGroup |  | ||||||
|           :buttons="[ |  | ||||||
|             { |  | ||||||
|               icon: $globals.icons.checkboxBlankOutline, |  | ||||||
|               text: $tc('shopping-list.uncheck-all-items'), |  | ||||||
|               event: 'uncheck', |  | ||||||
|             }, |  | ||||||
|             { |  | ||||||
|               icon: $globals.icons.checkboxOutline, |  | ||||||
|               text: $tc('shopping-list.check-all-items'), |  | ||||||
|               event: 'check', |  | ||||||
|             }, |  | ||||||
|           ]" |  | ||||||
|           @uncheck="bulkCheckIngredients(false)" |  | ||||||
|           @check="bulkCheckIngredients(true)" |  | ||||||
|         /> |  | ||||||
|       </div> |  | ||||||
|     </BaseDialog> |  | ||||||
|     <v-menu |     <v-menu | ||||||
|       offset-y |       offset-y | ||||||
|       left |       left | ||||||
| @@ -171,14 +106,14 @@ | |||||||
|  |  | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| import { computed, defineComponent, reactive, toRefs, useContext, useRoute, useRouter, ref } from "@nuxtjs/composition-api"; | import { computed, defineComponent, reactive, toRefs, useContext, useRoute, useRouter, ref } from "@nuxtjs/composition-api"; | ||||||
| import RecipeIngredientListItem from "./RecipeIngredientListItem.vue"; | import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue"; | ||||||
| import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue"; | import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue"; | ||||||
| import RecipeDialogShare from "./RecipeDialogShare.vue"; | import RecipeDialogShare from "./RecipeDialogShare.vue"; | ||||||
| import { useLoggedInState } from "~/composables/use-logged-in-state"; | import { useLoggedInState } from "~/composables/use-logged-in-state"; | ||||||
| import { useUserApi } from "~/composables/api"; | import { useUserApi } from "~/composables/api"; | ||||||
| import { alert } from "~/composables/use-toast"; | import { alert } from "~/composables/use-toast"; | ||||||
| import { usePlanTypeOptions } from "~/composables/use-group-mealplan"; | import { usePlanTypeOptions } from "~/composables/use-group-mealplan"; | ||||||
| import { Recipe, RecipeIngredient } from "~/lib/api/types/recipe"; | import { Recipe } from "~/lib/api/types/recipe"; | ||||||
| import { ShoppingListSummary } from "~/lib/api/types/group"; | import { ShoppingListSummary } from "~/lib/api/types/group"; | ||||||
| import { PlanEntryType } from "~/lib/api/types/meal-plan"; | import { PlanEntryType } from "~/lib/api/types/meal-plan"; | ||||||
| import { useAxiosDownloader } from "~/composables/api/use-axios-download"; | import { useAxiosDownloader } from "~/composables/api/use-axios-download"; | ||||||
| @@ -204,9 +139,9 @@ export interface ContextMenuItem { | |||||||
|  |  | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
|   components: { |   components: { | ||||||
|  |     RecipeDialogAddToShoppingList, | ||||||
|     RecipeDialogPrintPreferences, |     RecipeDialogPrintPreferences, | ||||||
|     RecipeDialogShare, |     RecipeDialogShare, | ||||||
|     RecipeIngredientListItem |  | ||||||
| }, | }, | ||||||
|   props: { |   props: { | ||||||
|     useItems: { |     useItems: { | ||||||
| @@ -279,7 +214,6 @@ export default defineComponent({ | |||||||
|       recipeDeleteDialog: false, |       recipeDeleteDialog: false, | ||||||
|       mealplannerDialog: false, |       mealplannerDialog: false, | ||||||
|       shoppingListDialog: false, |       shoppingListDialog: false, | ||||||
|       shoppingListIngredientDialog: false, |  | ||||||
|       recipeDuplicateDialog: false, |       recipeDuplicateDialog: false, | ||||||
|       recipeName: props.name, |       recipeName: props.name, | ||||||
|       loading: false, |       loading: false, | ||||||
| @@ -374,7 +308,7 @@ export default defineComponent({ | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Add leading and Apppending Items |     // Add leading and Appending Items | ||||||
|     state.menuItems = [...state.menuItems, ...props.leadingItems, ...props.appendItems]; |     state.menuItems = [...state.menuItems, ...props.leadingItems, ...props.appendItems]; | ||||||
|  |  | ||||||
|     const icon = props.menuIcon || $globals.icons.dotsVertical; |     const icon = props.menuIcon || $globals.icons.dotsVertical; | ||||||
| @@ -383,9 +317,8 @@ export default defineComponent({ | |||||||
|     // Context Menu Event Handler |     // Context Menu Event Handler | ||||||
|  |  | ||||||
|     const shoppingLists = ref<ShoppingListSummary[]>(); |     const shoppingLists = ref<ShoppingListSummary[]>(); | ||||||
|     const selectedShoppingList = ref<ShoppingListSummary>(); |  | ||||||
|     const recipeRef = ref<Recipe>(props.recipe); |     const recipeRef = ref<Recipe>(props.recipe); | ||||||
|     const recipeIngredients = ref<{ checked: boolean; ingredient: RecipeIngredient, disableAmount: boolean }[]>([]); |     const recipeRefWithScale = computed(() => recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined); | ||||||
|  |  | ||||||
|     async function getShoppingLists() { |     async function getShoppingLists() { | ||||||
|       const { data } = await api.shopping.lists.getAll(); |       const { data } = await api.shopping.lists.getAll(); | ||||||
| @@ -401,61 +334,6 @@ export default defineComponent({ | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     async function openShoppingListIngredientDialog(list: ShoppingListSummary) { |  | ||||||
|       selectedShoppingList.value = list; |  | ||||||
|       if (!recipeRef.value) { |  | ||||||
|         await refreshRecipe(); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       if (recipeRef.value?.recipeIngredient) { |  | ||||||
|         recipeIngredients.value = recipeRef.value.recipeIngredient.map((ingredient) => { |  | ||||||
|           return { |  | ||||||
|             checked: true, |  | ||||||
|             ingredient, |  | ||||||
|             disableAmount: recipeRef.value.settings?.disableAmount || false |  | ||||||
|           }; |  | ||||||
|         }); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       state.shoppingListDialog = false; |  | ||||||
|       state.shoppingListIngredientDialog = true; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     function bulkCheckIngredients(value = true) { |  | ||||||
|       recipeIngredients.value.forEach((data) => { |  | ||||||
|         data.checked = value; |  | ||||||
|       }); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     async function addRecipeToList() { |  | ||||||
|       if (!selectedShoppingList.value) { |  | ||||||
|         return; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       const ingredients: RecipeIngredient[] = []; |  | ||||||
|       recipeIngredients.value.forEach((data) => { |  | ||||||
|         if (data.checked) { |  | ||||||
|           ingredients.push(data.ingredient); |  | ||||||
|         } |  | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       if (!ingredients.length) { |  | ||||||
|         return; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       const { data } = await api.shopping.lists.addRecipe( |  | ||||||
|         selectedShoppingList.value.id, |  | ||||||
|         props.recipeId, |  | ||||||
|         props.recipeScale, |  | ||||||
|         ingredients |  | ||||||
|       ); |  | ||||||
|       if (data) { |  | ||||||
|         alert.success(i18n.t("recipe.recipe-added-to-list") as string); |  | ||||||
|         state.shoppingListDialog = false; |  | ||||||
|         state.shoppingListIngredientDialog = false; |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     const router = useRouter(); |     const router = useRouter(); | ||||||
|  |  | ||||||
|     async function deleteRecipe() { |     async function deleteRecipe() { | ||||||
| @@ -516,10 +394,12 @@ export default defineComponent({ | |||||||
|         state.printPreferencesDialog = true; |         state.printPreferencesDialog = true; | ||||||
|       }, |       }, | ||||||
|       shoppingList: () => { |       shoppingList: () => { | ||||||
|         getShoppingLists(); |         const promises: Promise<void>[] = [getShoppingLists()]; | ||||||
|  |         if (!recipeRef.value) { | ||||||
|  |           promises.push(refreshRecipe()); | ||||||
|  |         } | ||||||
|  |  | ||||||
|         state.shoppingListDialog = true; |         Promise.allSettled(promises).then(() => { state.shoppingListDialog = true }); | ||||||
|         state.shoppingListIngredientDialog = false; |  | ||||||
|       }, |       }, | ||||||
|       share: () => { |       share: () => { | ||||||
|         state.shareDialog = true; |         state.shareDialog = true; | ||||||
| @@ -544,28 +424,15 @@ export default defineComponent({ | |||||||
|     return { |     return { | ||||||
|       ...toRefs(state), |       ...toRefs(state), | ||||||
|       recipeRef, |       recipeRef, | ||||||
|  |       recipeRefWithScale, | ||||||
|       shoppingLists, |       shoppingLists, | ||||||
|       selectedShoppingList, |  | ||||||
|       openShoppingListIngredientDialog, |  | ||||||
|       addRecipeToList, |  | ||||||
|       bulkCheckIngredients, |  | ||||||
|       duplicateRecipe, |       duplicateRecipe, | ||||||
|       contextMenuEventHandler, |       contextMenuEventHandler, | ||||||
|       deleteRecipe, |       deleteRecipe, | ||||||
|       addRecipeToPlan, |       addRecipeToPlan, | ||||||
|       icon, |       icon, | ||||||
|       planTypeOptions, |       planTypeOptions, | ||||||
|       recipeIngredients, |  | ||||||
|     }; |     }; | ||||||
|   }, |   }, | ||||||
| }); | }); | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <style scoped lang="css"> |  | ||||||
| .ingredient-grid { |  | ||||||
|   display: grid; |  | ||||||
|   grid-auto-flow: column; |  | ||||||
|   grid-template-columns: 1fr 1fr; |  | ||||||
|   grid-gap: 0.5rem; |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
|   | |||||||
| @@ -0,0 +1,303 @@ | |||||||
|  | <template> | ||||||
|  |   <div v-if="dialog"> | ||||||
|  |     <BaseDialog v-if="shoppingListDialog" v-model="dialog" :title="$t('recipe.add-to-list')" :icon="$globals.icons.cartCheck"> | ||||||
|  |       <v-card-text> | ||||||
|  |         <v-card | ||||||
|  |           v-for="list in shoppingLists" | ||||||
|  |           :key="list.id" | ||||||
|  |           hover | ||||||
|  |           class="my-2 left-border" | ||||||
|  |           @click="openShoppingListIngredientDialog(list)" | ||||||
|  |         > | ||||||
|  |           <v-card-title class="py-2"> | ||||||
|  |             {{ list.name }} | ||||||
|  |           </v-card-title> | ||||||
|  |         </v-card> | ||||||
|  |       </v-card-text> | ||||||
|  |     </BaseDialog> | ||||||
|  |     <BaseDialog | ||||||
|  |       v-if="shoppingListIngredientDialog" | ||||||
|  |       v-model="dialog" | ||||||
|  |       :title="selectedShoppingList ? selectedShoppingList.name : $t('recipe.add-to-list')" | ||||||
|  |       :icon="$globals.icons.cartCheck" | ||||||
|  |       width="70%" | ||||||
|  |       :submit-text="$tc('recipe.add-to-list')" | ||||||
|  |       @submit="addRecipesToList()" | ||||||
|  |     > | ||||||
|  |       <div style="max-height: 70vh;  overflow-y: auto"> | ||||||
|  |         <v-card | ||||||
|  |           v-for="(section, sectionIndex) in recipeIngredientSections" :key="section.recipeId + sectionIndex" | ||||||
|  |           elevation="0" | ||||||
|  |           height="fit-content" | ||||||
|  |           width="100%" | ||||||
|  |         > | ||||||
|  |           <v-divider v-if="sectionIndex > 0" class="mt-3" /> | ||||||
|  |           <v-card-title | ||||||
|  |             v-if="recipeIngredientSections.length > 1" | ||||||
|  |             class="justify-center" | ||||||
|  |             width="100%" | ||||||
|  |           > | ||||||
|  |             <v-container style="width: 100%;"> | ||||||
|  |               <v-row no-gutters class="ma-0 pa-0"> | ||||||
|  |                 <v-col cols="12" align-self="center" class="text-center"> | ||||||
|  |                   {{ section.recipeName }} | ||||||
|  |                 </v-col> | ||||||
|  |               </v-row> | ||||||
|  |               <v-row v-if="section.recipeScale > 1" no-gutters class="ma-0 pa-0"> | ||||||
|  |                 <!-- TODO: make this editable in the dialog and visible on single-recipe lists --> | ||||||
|  |                 <v-col cols="12" align-self="center" class="text-center"> | ||||||
|  |                   ({{ $tc("recipe.quantity") }}: {{ section.recipeScale }}) | ||||||
|  |                 </v-col> | ||||||
|  |               </v-row> | ||||||
|  |             </v-container> | ||||||
|  |           </v-card-title> | ||||||
|  |           <div | ||||||
|  |             :class="$vuetify.breakpoint.smAndDown ? '' : 'ingredient-grid'" | ||||||
|  |             :style="$vuetify.breakpoint.smAndDown ? '' : { gridTemplateRows: `repeat(${Math.ceil(section.ingredients.length / 2)}, min-content)` }" | ||||||
|  |           > | ||||||
|  |             <v-list-item | ||||||
|  |               v-for="(ingredientData, i) in section.ingredients" | ||||||
|  |               :key="'ingredient' + i" | ||||||
|  |               dense | ||||||
|  |               @click="recipeIngredientSections[sectionIndex].ingredients[i].checked = !recipeIngredientSections[sectionIndex].ingredients[i].checked" | ||||||
|  |             > | ||||||
|  |               <v-checkbox | ||||||
|  |                 hide-details | ||||||
|  |                 :input-value="ingredientData.checked" | ||||||
|  |                 class="pt-0 my-auto py-auto" | ||||||
|  |                 color="secondary" | ||||||
|  |               /> | ||||||
|  |               <v-list-item-content :key="ingredientData.ingredient.quantity"> | ||||||
|  |                 <RecipeIngredientListItem | ||||||
|  |                   :ingredient="ingredientData.ingredient" | ||||||
|  |                   :disable-amount="ingredientData.disableAmount" | ||||||
|  |                   :scale="section.recipeScale" /> | ||||||
|  |               </v-list-item-content> | ||||||
|  |             </v-list-item> | ||||||
|  |           </div> | ||||||
|  |         </v-card> | ||||||
|  |       </div> | ||||||
|  |       <div class="d-flex justify-end mb-4 mt-2"> | ||||||
|  |         <BaseButtonGroup | ||||||
|  |           :buttons="[ | ||||||
|  |             { | ||||||
|  |               icon: $globals.icons.checkboxBlankOutline, | ||||||
|  |               text: $tc('shopping-list.uncheck-all-items'), | ||||||
|  |               event: 'uncheck', | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |               icon: $globals.icons.checkboxOutline, | ||||||
|  |               text: $tc('shopping-list.check-all-items'), | ||||||
|  |               event: 'check', | ||||||
|  |             }, | ||||||
|  |           ]" | ||||||
|  |           @uncheck="bulkCheckIngredients(false)" | ||||||
|  |           @check="bulkCheckIngredients(true)" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     </BaseDialog> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script lang="ts"> | ||||||
|  | import { computed, defineComponent, reactive, ref, useContext } from "@nuxtjs/composition-api"; | ||||||
|  | import { toRefs } from "@vueuse/core"; | ||||||
|  | import RecipeIngredientListItem from "./RecipeIngredientListItem.vue"; | ||||||
|  | import { useUserApi } from "~/composables/api"; | ||||||
|  | import { alert } from "~/composables/use-toast"; | ||||||
|  | import { ShoppingListSummary } from "~/lib/api/types/group"; | ||||||
|  | import { Recipe, RecipeIngredient } from "~/lib/api/types/recipe"; | ||||||
|  |  | ||||||
|  | export interface RecipeWithScale extends Recipe { | ||||||
|  |   scale: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface ShoppingListRecipeIngredient { | ||||||
|  |   checked: boolean; | ||||||
|  |   ingredient: RecipeIngredient; | ||||||
|  |   disableAmount: boolean; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface ShoppingListRecipeIngredientSection { | ||||||
|  |   recipeId: string; | ||||||
|  |   recipeName: string; | ||||||
|  |   recipeScale: number; | ||||||
|  |   ingredients: ShoppingListRecipeIngredient[]; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default defineComponent({ | ||||||
|  |   components: { | ||||||
|  |     RecipeIngredientListItem, | ||||||
|  |   }, | ||||||
|  |   props: { | ||||||
|  |     value: { | ||||||
|  |       type: Boolean, | ||||||
|  |       default: false, | ||||||
|  |     }, | ||||||
|  |     recipes: { | ||||||
|  |       type: Array as () => RecipeWithScale[], | ||||||
|  |       default: undefined, | ||||||
|  |     }, | ||||||
|  |     shoppingLists: { | ||||||
|  |       type: Array as () => ShoppingListSummary[], | ||||||
|  |       default: () => [], | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   setup(props, context) { | ||||||
|  |     const { i18n } = useContext(); | ||||||
|  |     const api = useUserApi(); | ||||||
|  |  | ||||||
|  |     // v-model support | ||||||
|  |     const dialog = computed({ | ||||||
|  |       get: () => { | ||||||
|  |         return props.value; | ||||||
|  |       }, | ||||||
|  |       set: (val) => { | ||||||
|  |         context.emit("input", val); | ||||||
|  |         initState(); | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     const state = reactive({ | ||||||
|  |       shoppingListDialog: true, | ||||||
|  |       shoppingListIngredientDialog: false, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]); | ||||||
|  |     const selectedShoppingList = ref<ShoppingListSummary | null>(null); | ||||||
|  |  | ||||||
|  |     async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) { | ||||||
|  |       const recipeSectionMap = new Map<string, ShoppingListRecipeIngredientSection>(); | ||||||
|  |       for (const recipe of recipes) { | ||||||
|  |         if (!recipe.slug) { | ||||||
|  |           continue; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (recipeSectionMap.has(recipe.slug)) { | ||||||
|  |           // @ts-ignore not undefined, see above | ||||||
|  |           recipeSectionMap.get(recipe.slug).recipeScale += recipe.scale; | ||||||
|  |           continue; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!(recipe.id && recipe.name && recipe.recipeIngredient)) { | ||||||
|  |           const { data } = await api.recipes.getOne(recipe.slug); | ||||||
|  |           if (!data?.recipeIngredient?.length) { | ||||||
|  |             continue; | ||||||
|  |           } | ||||||
|  |           recipe.id = data.id || ""; | ||||||
|  |           recipe.name = data.name || ""; | ||||||
|  |           recipe.recipeIngredient = data.recipeIngredient; | ||||||
|  |         } else if (!recipe.recipeIngredient.length) { | ||||||
|  |           continue; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         const shoppingListIngredients: ShoppingListRecipeIngredient[] = recipe.recipeIngredient.map((ing) => { | ||||||
|  |           return { | ||||||
|  |             checked: true, | ||||||
|  |             ingredient: ing, | ||||||
|  |             disableAmount: recipe.settings?.disableAmount || false, | ||||||
|  |           } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         recipeSectionMap.set(recipe.slug, { | ||||||
|  |           recipeId: recipe.id, | ||||||
|  |           recipeName: recipe.name, | ||||||
|  |           recipeScale: recipe.scale, | ||||||
|  |           ingredients: shoppingListIngredients, | ||||||
|  |         }) | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       recipeIngredientSections.value = Array.from(recipeSectionMap.values()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function initState() { | ||||||
|  |       state.shoppingListDialog = true; | ||||||
|  |       state.shoppingListIngredientDialog = false; | ||||||
|  |       recipeIngredientSections.value = []; | ||||||
|  |       selectedShoppingList.value = null; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     initState(); | ||||||
|  |  | ||||||
|  |     async function openShoppingListIngredientDialog(list: ShoppingListSummary) { | ||||||
|  |       if (!props.recipes?.length) { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       selectedShoppingList.value = list; | ||||||
|  |       await consolidateRecipesIntoSections(props.recipes); | ||||||
|  |       state.shoppingListDialog = false; | ||||||
|  |       state.shoppingListIngredientDialog = true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function bulkCheckIngredients(value = true) { | ||||||
|  |       recipeIngredientSections.value.forEach((section) => { | ||||||
|  |         section.ingredients.forEach((ing) => { | ||||||
|  |           ing.checked = value; | ||||||
|  |         }); | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async function addRecipesToList() { | ||||||
|  |       const promises: Promise<any>[] = []; | ||||||
|  |       recipeIngredientSections.value.forEach((section) => { | ||||||
|  |         if (!selectedShoppingList.value) { | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         const ingredients: RecipeIngredient[] = []; | ||||||
|  |         section.ingredients.forEach((ing) => { | ||||||
|  |           if (ing.checked) { | ||||||
|  |             ingredients.push(ing.ingredient); | ||||||
|  |           } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         if (!ingredients.length) { | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         promises.push(api.shopping.lists.addRecipe( | ||||||
|  |           selectedShoppingList.value.id, | ||||||
|  |           section.recipeId, | ||||||
|  |           section.recipeScale, | ||||||
|  |           ingredients, | ||||||
|  |         )); | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       let success = true; | ||||||
|  |       const results = await Promise.allSettled(promises); | ||||||
|  |       results.forEach((result) => { | ||||||
|  |         if (result.status === "rejected") { | ||||||
|  |           success = false; | ||||||
|  |         } | ||||||
|  |       }) | ||||||
|  |  | ||||||
|  |       success ? alert.success(i18n.t("recipe.recipes-added-to-list") as string) | ||||||
|  |       : alert.error(i18n.t("failed-to-add-recipes-to-list") as string) | ||||||
|  |  | ||||||
|  |       state.shoppingListDialog = false; | ||||||
|  |       state.shoppingListIngredientDialog = false; | ||||||
|  |       dialog.value = false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return { | ||||||
|  |       dialog, | ||||||
|  |       ...toRefs(state), | ||||||
|  |       addRecipesToList, | ||||||
|  |       bulkCheckIngredients, | ||||||
|  |       openShoppingListIngredientDialog, | ||||||
|  |       recipeIngredientSections, | ||||||
|  |       selectedShoppingList, | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  | }) | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style scoped lang="css"> | ||||||
|  | .ingredient-grid { | ||||||
|  |   display: grid; | ||||||
|  |   grid-auto-flow: column; | ||||||
|  |   grid-template-columns: 1fr 1fr; | ||||||
|  |   grid-gap: 0.5rem; | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -463,7 +463,9 @@ | |||||||
|     "add-to-plan": "Add to Plan", |     "add-to-plan": "Add to Plan", | ||||||
|     "add-to-timeline": "Add to Timeline", |     "add-to-timeline": "Add to Timeline", | ||||||
|     "recipe-added-to-list": "Recipe added to list", |     "recipe-added-to-list": "Recipe added to list", | ||||||
|  |     "recipes-added-to-list": "Recipes added to list", | ||||||
|     "recipe-added-to-mealplan": "Recipe added to mealplan", |     "recipe-added-to-mealplan": "Recipe added to mealplan", | ||||||
|  |     "failed-to-add-recipes-to-list": "Failed to add recipe to list", | ||||||
|     "failed-to-add-recipe-to-mealplan": "Failed to add recipe to mealplan", |     "failed-to-add-recipe-to-mealplan": "Failed to add recipe to mealplan", | ||||||
|     "yield": "Yield", |     "yield": "Yield", | ||||||
|     "quantity": "Quantity", |     "quantity": "Quantity", | ||||||
|   | |||||||
| @@ -1,51 +1,65 @@ | |||||||
| <template> | <template> | ||||||
|   <v-row> |   <v-container class="mx-0 my-3 pa"> | ||||||
|     <v-col |     <v-row> | ||||||
|       v-for="(day, index) in plan" |       <v-col | ||||||
|       :key="index" |         v-for="(day, index) in plan" | ||||||
|       cols="12" |         :key="index" | ||||||
|       sm="12" |         cols="12" | ||||||
|       md="4" |         sm="12" | ||||||
|       lg="4" |         md="4" | ||||||
|       xl="2" |         lg="4" | ||||||
|       class="col-borders my-1 d-flex flex-column" |         xl="2" | ||||||
|     > |         class="col-borders my-1 d-flex flex-column" | ||||||
|       <v-card class="mb-2 border-left-primary rounded-sm pa-2"> |       > | ||||||
|         <p class="pl-2 mb-1"> |         <v-card class="mb-2 border-left-primary rounded-sm px-2"> | ||||||
|           {{ $d(day.date, "short") }} |           <v-container class="px-0"> | ||||||
|         </p> |             <v-row no-gutters style="width: 100%;"> | ||||||
|       </v-card> |               <v-col cols="10"> | ||||||
|       <div v-for="section in day.sections" :key="section.title"> |                 <p class="pl-2 my-1"> | ||||||
|         <div class="py-2 d-flex flex-column"> |                   {{ $d(day.date, "short") }} | ||||||
|           <div class="primary" style="width: 50px; height: 2.5px"></div> |                 </p> | ||||||
|           <p class="text-overline my-0"> |               </v-col> | ||||||
|             {{ section.title }} |               <v-col class="d-flex justify-top" cols="2"> | ||||||
|           </p> |                 <GroupMealPlanDayContextMenu v-if="day.recipes.length" :recipes="day.recipes" /> | ||||||
|         </div> |               </v-col> | ||||||
|  |             </v-row> | ||||||
|  |           </v-container> | ||||||
|  |         </v-card> | ||||||
|  |         <div v-for="section in day.sections" :key="section.title"> | ||||||
|  |           <div class="py-2 d-flex flex-column"> | ||||||
|  |             <div class="primary" style="width: 50px; height: 2.5px"></div> | ||||||
|  |             <p class="text-overline my-0"> | ||||||
|  |               {{ section.title }} | ||||||
|  |             </p> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|         <RecipeCardMobile |           <RecipeCardMobile | ||||||
|           v-for="mealplan in section.meals" |             v-for="mealplan in section.meals" | ||||||
|           :key="mealplan.id" |             :key="mealplan.id" | ||||||
|           :recipe-id="mealplan.recipe ? mealplan.recipe.id : ''" |             :recipe-id="mealplan.recipe ? mealplan.recipe.id : ''" | ||||||
|           class="mb-2" |             class="mb-2" | ||||||
|           :route="mealplan.recipe ? true : false" |             :route="mealplan.recipe ? true : false" | ||||||
|           :slug="mealplan.recipe ? mealplan.recipe.slug : mealplan.title" |             :slug="mealplan.recipe ? mealplan.recipe.slug : mealplan.title" | ||||||
|           :description="mealplan.recipe ? mealplan.recipe.description : mealplan.text" |             :description="mealplan.recipe ? mealplan.recipe.description : mealplan.text" | ||||||
|           :name="mealplan.recipe ? mealplan.recipe.name : mealplan.title" |             :name="mealplan.recipe ? mealplan.recipe.name : mealplan.title" | ||||||
|         /> |           /> | ||||||
|       </div> |         </div> | ||||||
|     </v-col> |       </v-col> | ||||||
|   </v-row> |     </v-row> | ||||||
|  |   </v-container> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| import { computed, defineComponent, useContext } from "@nuxtjs/composition-api"; | import { computed, defineComponent, useContext } from "@nuxtjs/composition-api"; | ||||||
| import { MealsByDate } from "./types"; | import { MealsByDate } from "./types"; | ||||||
| import { ReadPlanEntry } from "~/lib/api/types/meal-plan"; | import { ReadPlanEntry } from "~/lib/api/types/meal-plan"; | ||||||
|  | import GroupMealPlanDayContextMenu from "~/components/Domain/Group/GroupMealPlanDayContextMenu.vue"; | ||||||
| import RecipeCardMobile from "~/components/Domain/Recipe/RecipeCardMobile.vue"; | import RecipeCardMobile from "~/components/Domain/Recipe/RecipeCardMobile.vue"; | ||||||
|  | import { RecipeSummary } from "~/lib/api/types/recipe"; | ||||||
|  |  | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
|   components: { |   components: { | ||||||
|  |     GroupMealPlanDayContextMenu, | ||||||
|     RecipeCardMobile, |     RecipeCardMobile, | ||||||
|   }, |   }, | ||||||
|   props: { |   props: { | ||||||
| @@ -63,6 +77,7 @@ export default defineComponent({ | |||||||
|     type Days = { |     type Days = { | ||||||
|       date: Date; |       date: Date; | ||||||
|       sections: DaySection[]; |       sections: DaySection[]; | ||||||
|  |       recipes: RecipeSummary[]; | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|   const { i18n } = useContext(); |   const { i18n } = useContext(); | ||||||
| @@ -77,6 +92,7 @@ export default defineComponent({ | |||||||
|             { title: i18n.tc("meal-plan.dinner"), meals: [] }, |             { title: i18n.tc("meal-plan.dinner"), meals: [] }, | ||||||
|             { title: i18n.tc("meal-plan.side"), meals: [] }, |             { title: i18n.tc("meal-plan.side"), meals: [] }, | ||||||
|           ], |           ], | ||||||
|  |           recipes: [], | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         for (const meal of day.meals) { |         for (const meal of day.meals) { | ||||||
| @@ -89,6 +105,10 @@ export default defineComponent({ | |||||||
|           } else if (meal.entryType === "side") { |           } else if (meal.entryType === "side") { | ||||||
|             out.sections[3].meals.push(meal); |             out.sections[3].meals.push(meal); | ||||||
|           } |           } | ||||||
|  |  | ||||||
|  |           if (meal.recipe) { | ||||||
|  |             out.recipes.push(meal.recipe); | ||||||
|  |           } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Drop empty sections |         // Drop empty sections | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user