Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/docs/overrides/api.html

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion frontend/components/Domain/Recipe/RecipeCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
<slot name="actions">
<v-card-actions
v-if="showRecipeContent"
class="px-1"
class="px-1 ga-0"
>
<RecipeFavoriteBadge
v-if="isOwnGroup"
Expand All @@ -54,9 +54,16 @@
/>
<div v-else class="px-1" /> <!-- Empty div to keep the layout consistent -->

<RecipeMealPlanBadge
v-if="isOwnGroup && !hideMealPlanBadge"
:recipe-id="recipeId"
show-always
/>

<RecipeCardRating
:model-value="rating"
:recipe-id="recipeId"
class="pa-2"
/>
<v-spacer />
<RecipeChips
Expand Down Expand Up @@ -115,6 +122,7 @@ interface Props {
tags?: Array<any>;
recipeId: string;
imageHeight?: number;
hideMealPlanBadge?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
description: null,
Expand All @@ -123,6 +131,7 @@ const props = withDefaults(defineProps<Props>(), {
image: "abc123",
tags: () => [],
imageHeight: 200,
hideMealPlanBadge: false,
});

defineEmits<{
Expand Down
14 changes: 12 additions & 2 deletions frontend/components/Domain/Recipe/RecipeCardMobile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -79,17 +79,25 @@
</div>
</div>
<slot name="actions">
<v-card-actions class="w-100 my-0 px-1 py-0">
<v-card-actions class="w-100 my-0 px-1 py-0 ga-0">
<RecipeFavoriteBadge
v-if="isOwnGroup && showRecipeContent"
:recipe-id="recipeId"
show-always
class="ma-0 pa-0"
/>
<div v-else class="my-0 px-1 py-0" /> <!-- Empty div to keep the layout consistent -->

<RecipeMealPlanBadge
v-if="isOwnGroup && showRecipeContent && !hideMealPlanBadge"
:recipe-id="recipeId"
show-always
class="ma-0 pa-0"
/>

<RecipeCardRating
v-if="showRecipeContent"
:class="[{ 'pb-2': !isOwnGroup }, 'ml-n2']"
:class="[{ 'pb-2': !isOwnGroup }, 'px-2']"
:model-value="rating"
:recipe-id="recipeId"
/>
Expand Down Expand Up @@ -144,6 +152,7 @@ interface Props {
isFlat?: boolean;
height?: number;
disableHighlight?: boolean;
hideMealPlanBadge?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
rating: 0,
Expand All @@ -153,6 +162,7 @@ const props = withDefaults(defineProps<Props>(), {
isFlat: false,
height: 150,
disableHighlight: false,
hideMealPlanBadge: false,
});

defineEmits<{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,15 @@
@confirm="addRecipeToPlan()"
>
<v-card-text>
<v-switch
v-model="assigned"
:label="$t('meal-plan.plan-for-specific-day')"
color="primary"
class="mb-3"
hide-details
/>
<v-date-picker
v-if="assigned"
v-model="newMealdate"
class="mx-auto mb-3"
hide-header
Expand All @@ -55,6 +63,7 @@
:local="$i18n.locale"
/>
<v-select
v-if="assigned"
v-model="newMealType"
:return-object="false"
:items="planTypeOptions"
Expand Down Expand Up @@ -110,6 +119,7 @@ import { useGroupRecipeActions } from "~/composables/use-group-recipe-actions";
import { useHouseholdSelf } from "~/composables/use-households";
import { alert } from "~/composables/use-toast";
import { usePlanTypeOptions } from "~/composables/use-group-mealplan";
import { useRecipeMealPlans } from "~/composables/use-recipe-mealplans";
import type { Recipe } from "~/lib/api/types/recipe";
import type { GroupRecipeActionOut, ShoppingListSummary } from "~/lib/api/types/household";
import type { PlanEntryType } from "~/lib/api/types/meal-plan";
Expand Down Expand Up @@ -192,6 +202,7 @@ const loading = ref(false);
const menuItems = ref<ContextMenuItem[]>([]);
const newMealdate = ref(new Date());
const newMealType = ref<PlanEntryType>("dinner");
const assigned = ref(true);

const newMealdateString = computed(() => {
// Format the date to YYYY-MM-DD in the same timezone as newMealdate
Expand All @@ -206,6 +217,7 @@ const auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { household } = useHouseholdSelf();
const { isOwnGroup } = useLoggedInState();
const { refreshMealPlans } = useRecipeMealPlans();

const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || auth.user.value?.groupSlug || "");
Expand Down Expand Up @@ -373,18 +385,29 @@ async function handleDownloadEvent() {

async function addRecipeToPlan() {
const { response } = await api.mealplans.createOne({
date: newMealdateString.value,
entryType: newMealType.value,
date: assigned.value ? newMealdateString.value : null,
entryType: assigned.value ? newMealType.value : null,
title: "",
text: "",
recipeId: props.recipeId,
});

if (response?.status === 201) {
alert.success(i18n.t("recipe.recipe-added-to-mealplan") as string);
await refreshMealPlans();
if (assigned.value) {
alert.success(i18n.t("recipe.recipe-added-to-mealplan") as string);
}
else {
alert.success(i18n.t("recipe.recipe-added-to-mealplan-unassigned") as string);
}
}
else {
alert.error(i18n.t("recipe.failed-to-add-recipe-to-mealplan") as string);
if (assigned.value) {
alert.error(i18n.t("recipe.failed-to-add-recipe-to-mealplan") as string);
}
else {
alert.error(i18n.t("recipe.failed-to-add-recipe-without-date") as string);
}
}
}

Expand Down
176 changes: 176 additions & 0 deletions frontend/components/Domain/Recipe/RecipeMealPlanBadge.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
<template>
<v-tooltip
location="bottom"
nudge-right="50"
:color="buttonStyle ? 'info' : 'secondary'"
>
<template #activator="{ props: tooltipProps }">
<v-btn
v-if="isInMealPlan || showAlways"
icon
:variant="buttonStyle ? 'flat' : undefined"
:rounded="buttonStyle ? 'circle' : undefined"
size="small"
:color="buttonStyle ? 'info' : 'secondary'"
:fab="buttonStyle"
v-bind="{ ...tooltipProps, ...$attrs }"
@click.prevent="handleClick"
>
<v-icon
:size="!buttonStyle ? undefined : 'x-large'"
:color="buttonStyle ? 'white' : 'secondary'"
>
{{ isInMealPlan ? $globals.icons.calendarCheck : $globals.icons.calendarBlank }}
</v-icon>
</v-btn>
</template>
<span>{{ tooltipText }}</span>
</v-tooltip>

<!-- Confirmation Dialog for Dated Entries -->
<BaseDialog
v-model="confirmDialog"
:title="$t('recipe.scheduled-mealplan-entries')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="removeAllEntries"
>
<v-card-text>
<p class="mb-4">
{{ $t("recipe.confirm-remove-scheduled-entries") }}
</p>
<v-list>
<v-list-item
v-for="plan in sortedDatedPlans"
:key="plan.id"
>
<v-list-item-title>
{{ formatDateWithDay(plan.date) }}
<span v-if="plan.entryType"> - {{ $t(`meal-plan.${plan.entryType}`) }}</span>
</v-list-item-title>
</v-list-item>
</v-list>
</v-card-text>
</BaseDialog>
</template>

<script setup lang="ts">
import { useRecipeMealPlans } from "~/composables/use-recipe-mealplans";
import { alert } from "~/composables/use-toast";

interface Props {
recipeId?: string;
showAlways?: boolean;
buttonStyle?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
recipeId: "",
showAlways: false,
buttonStyle: false,
});

const i18n = useI18n();
const { getMealPlansForRecipe, addToMealPlanWithoutDate, removeMealPlanEntries } = useRecipeMealPlans();

const confirmDialog = ref(false);

const mealPlans = computed(() => getMealPlansForRecipe(props.recipeId));
const isInMealPlan = computed(() => mealPlans.value.length > 0);

const datedPlans = computed(() => mealPlans.value.filter(plan => plan.date !== null && plan.date !== undefined));
const unassignedPlans = computed(() => mealPlans.value.filter(plan => plan.date === null || plan.date === undefined));

// Sort dated plans by date (earliest first), then by entry type
const sortedDatedPlans = computed(() => {
const entryTypeOrder = ["breakfast", "lunch", "dinner", "side", "snack", "drink", "dessert"];

return [...datedPlans.value].sort((a, b) => {
// First sort by date (earliest first)
const dateA = a.date ? new Date(a.date).getTime() : 0;
const dateB = b.date ? new Date(b.date).getTime() : 0;
if (dateA !== dateB) {
return dateA - dateB;
}

// Then sort by entry type
const typeA = a.entryType || "";
const typeB = b.entryType || "";
return entryTypeOrder.indexOf(typeA) - entryTypeOrder.indexOf(typeB);
});
});

const tooltipText = computed(() => {
if (!isInMealPlan.value) {
return i18n.t("recipe.add-to-plan");
}

// Only unassigned entries
if (datedPlans.value.length === 0) {
return i18n.t("recipe.in-mealplan-without-date");
}

// Has dated entries
const firstDate = formatDateWithDay(sortedDatedPlans.value[0].date);

if (sortedDatedPlans.value.length === 1) {
return i18n.t("recipe.in-mealplan-with-date", { date: firstDate });
}

// Multiple dated entries
const additionalCount = sortedDatedPlans.value.length - 1;
return i18n.t("recipe.in-mealplan-with-date-and-more", { date: firstDate, count: additionalCount });
});

function formatDateWithDay(dateString: string | null | undefined): string {
if (!dateString) return "";
const date = new Date(dateString);
return date.toLocaleDateString(i18n.locale.value, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
});
}

async function handleClick() {
if (!isInMealPlan.value) {
// Not in meal plan - add it without date
const result = await addToMealPlanWithoutDate(props.recipeId);
if (result.success) {
alert.success(i18n.t("recipe.recipe-added-to-mealplan-unassigned") as string);
}
else {
alert.error(i18n.t("recipe.failed-to-add-recipe-without-date") as string);
}
}
else if (datedPlans.value.length === 0) {
// Only unassigned entries - remove them directly
const planIds = unassignedPlans.value.map(p => p.id);
const result = await removeMealPlanEntries(planIds);
if (result.success) {
alert.success(i18n.t("recipe.recipe-removed-from-mealplan") as string);
}
else {
alert.error(i18n.t("recipe.recipe-removed-from-mealplan-failed") as string);
}
}
else {
// Has dated entries - show confirmation dialog
confirmDialog.value = true;
}
}

async function removeAllEntries() {
const planIds = mealPlans.value.map(p => p.id);
const result = await removeMealPlanEntries(planIds);
confirmDialog.value = false;

if (result.success) {
alert.success(i18n.t("recipe.recipe-removed-from-mealplan") as string);
}
else {
alert.error(i18n.t("recipe.recipe-removed-from-mealplan-failed") as string);
}
}
</script>
Loading