{{ recipe.name }}
- @if (recipe.prepTimeMinutes != null) { +{{ displayRecipe().name }}
+ @if (displayRecipe().prepTimeMinutes != null) {- {{ 'recipes.prepTime' | translate: { count: recipe.prepTimeMinutes } }} + {{ 'recipes.prepTime' | translate: { count: displayRecipe().prepTimeMinutes } }}
}- {{ recipe.calories ?? '—' }} - @if (recipe.calories != null) { - kcal + @for (macro of macroCards(); track macro.label) { +
+ {{ macro.value ?? '—' }} + @if (macro.value != null) { + {{ macro.suffix }} + } +
++ {{ macro.label | translate }} +
+{{ 'recipes.recalculateCaloriesTitle' | translate }}
+{{ 'recipes.recalculateCaloriesHint' | translate }}
++ {{ + 'recipes.recalculatePreview' + | translate: { + previous: preview()!.previousCalories ?? '—', + next: preview()!.newCalories ?? '—', + target: preview()!.targetCalories, + } + }} + @if (preview()!.unlinkedIngredientCount > 0) { + {{ 'recipes.recalculateUnlinkedHint' | translate: { count: preview()!.unlinkedIngredientCount } }} }
-- {{ 'recipes.calories' | translate }} -
-- {{ recipe.protein ?? '—' }} - @if (recipe.protein != null) { - g - } -
-- {{ 'recipes.protein' | translate }} -
-- {{ recipe.fat ?? '—' }} - @if (recipe.fat != null) { - g - } -
-- {{ 'recipes.fat' | translate }} -
-- {{ recipe.carbs ?? '—' }} - @if (recipe.carbs != null) { - g - } -
-- {{ 'recipes.carbs' | translate }} -
-{{ error() }}
+ }{{ 'recipes.ingredients' | translate }}
- @if (recipe.ingredients.length === 0) { + @if (displayRecipe().ingredients.length === 0) {{{ 'recipes.noIngredients' | translate }}
} @else {{{ 'recipes.preparation' | translate }}
- @if (recipe.steps.length === 0) { + @if (displayRecipe().steps.length === 0) {{{ 'recipes.noSteps' | translate }}
} @else {-
- @for (step of recipe.steps; track step.id) {
+ @for (step of displayRecipe().steps; track step.id) {
-
{{ step.stepNumber }}
@@ -122,21 +151,73 @@ type IngredientSortColumn = 'name' | 'amount';
`,
})
-export class RecipeDetailViewComponent {
+export class RecipeDetailViewComponent implements OnChanges {
+ private readonly management = inject(RecipeManagementService);
+ private readonly translate = inject(TranslateService);
+
@Input({ required: true }) recipe!: RecipeDetail;
+ @Output() recipeUpdated = new EventEmitter
(); + + readonly preview = signal (null); + readonly pending = signal(false); + readonly applyPending = signal(false); + readonly error = signal (null); + targetCalories = ''; readonly ingredientSort = signal<{ column: IngredientSortColumn; direction: SortDirection }>({ column: 'name', direction: 'asc', }); + readonly originalAmounts = computed( + () => new Map(this.recipe.ingredients.map((ing) => [ing.id, ing.amountGrams])), + ); + + readonly displayRecipe = computed(() => { + const preview = this.preview(); + if (preview?.applied && preview.recipe) return preview.recipe; + if (preview) { + return { + ...this.recipe, + calories: preview.newCalories ?? this.recipe.calories, + protein: preview.newProtein ?? this.recipe.protein, + fat: preview.newFat ?? this.recipe.fat, + carbs: preview.newCarbs ?? this.recipe.carbs, + ingredients: preview.ingredients, + }; + } + return this.recipe; + }); + + readonly showingPreview = computed(() => { + const preview = this.preview(); + return preview != null && !preview.applied; + }); + + readonly macroCards = computed(() => { + const recipe = this.displayRecipe(); + const highlight = this.showingPreview(); + return [ + { label: 'recipes.calories', value: recipe.calories, suffix: 'kcal', accent: 'text-orange-500', highlight }, + { label: 'recipes.protein', value: recipe.protein, suffix: 'g', accent: 'text-brand-600', highlight }, + { label: 'recipes.fat', value: recipe.fat, suffix: 'g', accent: 'text-amber-500', highlight }, + { label: 'recipes.carbs', value: recipe.carbs, suffix: 'g', accent: 'text-sky-500', highlight }, + ]; + }); + readonly sortedIngredients = computed(() => { const { column, direction } = this.ingredientSort(); - return sortBy(this.recipe.ingredients, direction, (ing) => + return sortBy(this.displayRecipe().ingredients, direction, (ing) => column === 'name' ? ing.name : ing.amountGrams, ); }); + ngOnChanges(): void { + if (this.recipe && !this.targetCalories) { + this.targetCalories = this.recipe.calories != null ? String(this.recipe.calories) : ''; + } + } + formatAmount(amount: number | null, unit: string | null): string { if (amount == null && !unit) return ''; if (amount == null) return unit ?? ''; @@ -144,6 +225,12 @@ export class RecipeDetailViewComponent { return unit ? `${display} ${unit}` : `${display} g`; } + amountChanged(id: number, amount: number | null): boolean { + if (!this.showingPreview()) return false; + const previous = this.originalAmounts().get(id); + return previous != null && amount != null && previous !== amount; + } + toggleIngredientSort(column: IngredientSortColumn): void { this.ingredientSort.update((current) => nextSort(current, column)); } @@ -151,4 +238,34 @@ export class RecipeDetailViewComponent { ingredientIndicator(column: IngredientSortColumn): string { return sortIndicator(this.ingredientSort(), column); } + + runRecalculate(apply: boolean): void { + const target = Number(this.targetCalories); + if (!Number.isFinite(target) || target < 50) { + this.error.set(this.translate.instant('recipes.recalculateCaloriesInvalid')); + return; + } + + this.error.set(null); + if (apply) this.applyPending.set(true); + else this.pending.set(true); + + this.management.recalculateRecipeCalories(this.recipe.id, target, apply).subscribe({ + next: (result) => { + this.preview.set(result); + if (result.applied && result.recipe) { + this.recipe = result.recipe; + this.recipeUpdated.emit(result.recipe); + this.preview.set(null); + } + this.pending.set(false); + this.applyPending.set(false); + }, + error: (err) => { + this.error.set(extractApiError(err, this.translate.instant('recipes.recalculateCaloriesFailed'))); + this.pending.set(false); + this.applyPending.set(false); + }, + }); + } } diff --git a/meal-plan-frontend/src/api/recipe-management.api.ts b/meal-plan-frontend/src/api/recipe-management.api.ts index 30603e8..644b107 100644 --- a/meal-plan-frontend/src/api/recipe-management.api.ts +++ b/meal-plan-frontend/src/api/recipe-management.api.ts @@ -1,6 +1,6 @@ import { api } from "./axiosInstance"; import type { RecipeDetail } from "@/types/recipe.types"; -import type { CreateRecipeInput, RecipeImportResult, RecipeMacroRecalcResult } from "@/types/recipe-management.types"; +import type { CreateRecipeInput, RecipeImportResult, RecipeMacroRecalcResult, RecalculateRecipeCaloriesResult } from "@/types/recipe-management.types"; function toPayload(input: CreateRecipeInput) { return { @@ -55,3 +55,15 @@ export async function recalculateAllRecipeMacros(): Promise ("/recipes/recalculate-macros"); return data; } + +export async function recalculateRecipeCalories( + recipeId: number, + targetCalories: number, + apply = false, +): Promise { + const { data } = await api.post ( + `/recipes/${recipeId}/recalculate-calories`, + { targetCalories, apply }, + ); + return data; +} diff --git a/meal-plan-frontend/src/components/recipes/RecipeDetail.tsx b/meal-plan-frontend/src/components/recipes/RecipeDetail.tsx index 507a63f..e98a95f 100644 --- a/meal-plan-frontend/src/components/recipes/RecipeDetail.tsx +++ b/meal-plan-frontend/src/components/recipes/RecipeDetail.tsx @@ -1,8 +1,11 @@ import { useMemo, useState } from "react"; -import { Clock } from "lucide-react"; +import { Clock, Loader2, RefreshCw, Save } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { recalculateRecipeCalories } from "@/api/recipe-management.api"; +import { extractApiError } from "@/api/axiosInstance"; import { CategoryBadge } from "@/components/ui/CategoryBadge"; import type { RecipeDetail as RecipeDetailModel } from "@/types/recipe.types"; +import type { RecalculateRecipeCaloriesResult } from "@/types/recipe-management.types"; import { nextSort, sortBy, sortIndicator, type SortDirection } from "@/utils/sort.util"; type IngredientSortColumn = "name" | "amount"; @@ -12,11 +15,18 @@ interface MacroProps { value: number | null; suffix: string; accent: string; + highlight?: boolean; } -function Macro({ label, value, suffix, accent }: MacroProps) { +function Macro({ label, value, suffix, accent, highlight }: MacroProps) { return ( - +{value != null ? value : "—"} {value != null && {suffix}} @@ -37,51 +47,198 @@ function formatAmount(amount: number | null, unit: string | null): string { interface RecipeDetailProps { recipe: RecipeDetailModel; + onRecipeUpdated?: (recipe: RecipeDetailModel) => void; } -export function RecipeDetail({ recipe }: RecipeDetailProps) { +function mergePreview(recipe: RecipeDetailModel, preview: RecalculateRecipeCaloriesResult): RecipeDetailModel { + return { + ...recipe, + calories: preview.newCalories ?? recipe.calories, + protein: preview.newProtein ?? recipe.protein, + fat: preview.newFat ?? recipe.fat, + carbs: preview.newCarbs ?? recipe.carbs, + ingredients: preview.ingredients.map((ing) => ({ + id: ing.id, + name: ing.name, + amountGrams: ing.amountGrams, + unit: ing.unit, + sortOrder: ing.sortOrder, + catalogItemId: ing.catalogItemId, + })), + }; +} + +export function RecipeDetail({ recipe, onRecipeUpdated }: RecipeDetailProps) { const { t } = useTranslation(); const [ingredientSort, setIngredientSort] = useState<{ column: IngredientSortColumn; direction: SortDirection; }>({ column: "name", direction: "asc" }); + const [targetCalories, setTargetCalories] = useState( + recipe.calories != null ? String(recipe.calories) : "", + ); + const [preview, setPreview] = useState
(null); + const [pending, setPending] = useState(false); + const [applyPending, setApplyPending] = useState(false); + const [error, setError] = useState (null); + + const displayRecipe = useMemo(() => { + if (preview?.applied && preview.recipe) return preview.recipe; + if (preview) return mergePreview(recipe, preview); + return recipe; + }, [preview, recipe]); + + const originalAmounts = useMemo( + () => new Map(recipe.ingredients.map((ing) => [ing.id, ing.amountGrams])), + [recipe.ingredients], + ); const sortedIngredients = useMemo( () => - sortBy(recipe.ingredients, ingredientSort.direction, (ing) => + sortBy(displayRecipe.ingredients, ingredientSort.direction, (ing) => ingredientSort.column === "name" ? ing.name : ing.amountGrams, ), - [recipe.ingredients, ingredientSort], + [displayRecipe.ingredients, ingredientSort], ); const toggleIngredientSort = (column: IngredientSortColumn) => { setIngredientSort((current) => nextSort(current, column)); }; + const runRecalculate = async (apply: boolean) => { + const target = Number(targetCalories); + if (!Number.isFinite(target) || target < 50) { + setError(t("recipes.recalculateCaloriesInvalid")); + return; + } + + setError(null); + if (apply) setApplyPending(true); + else setPending(true); + + try { + const result = await recalculateRecipeCalories(recipe.id, target, apply); + setPreview(result); + if (result.applied && result.recipe) { + onRecipeUpdated?.(result.recipe); + setPreview(null); + } + } catch (e) { + setError(extractApiError(e, t("recipes.recalculateCaloriesFailed"))); + } finally { + setPending(false); + setApplyPending(false); + } + }; + + const showingPreview = preview != null && !preview.applied; + return ( - - {recipe.name}
- {recipe.prepTimeMinutes != null && ( ++ {displayRecipe.name}
+ {displayRecipe.prepTimeMinutes != null && (
)}- {t("recipes.prepTime", { count: recipe.prepTimeMinutes })} + {t("recipes.prepTime", { count: displayRecipe.prepTimeMinutes })} - + +- - - + + + + + + {t("recipes.recalculateCaloriesTitle")}
+{t("recipes.recalculateCaloriesHint")}
++ + + {showingPreview ? ( + + ) : null} ++ {showingPreview ? ( ++ {t("recipes.recalculatePreview", { + previous: preview.previousCalories ?? "—", + next: preview.newCalories ?? "—", + target: preview.targetCalories, + })} + {preview.unlinkedIngredientCount > 0 + ? ` ${t("recipes.recalculateUnlinkedHint", { count: preview.unlinkedIngredientCount })}` + : ""} +
+ ) : null} + {error ? ( ++ {error} +
+ ) : null})} @@ -119,11 +293,11 @@ export function RecipeDetail({ recipe }: RecipeDetailProps) {{t("recipes.ingredients")}
- {recipe.ingredients.length === 0 ? ( + {displayRecipe.ingredients.length === 0 ? ({t("recipes.noIngredients")}
) : (@@ -104,14 +261,31 @@ export function RecipeDetail({ recipe }: RecipeDetailProps) {-
- {sortedIngredients.map((ing) => (
-
- - {ing.name} - - {formatAmount(ing.amountGrams, ing.unit)} - - - ))} + {sortedIngredients.map((ing) => { + const previous = originalAmounts.get(ing.id); + const changed = + showingPreview && + previous != null && + ing.amountGrams != null && + previous !== ing.amountGrams; + return ( +
- + {ing.name} + + {formatAmount(ing.amountGrams, ing.unit)} + {changed ? ( + + {formatAmount(previous, ing.unit)} + + ) : null} + + + ); + })}
{t("recipes.preparation")}
- {recipe.steps.length === 0 ? ( + {displayRecipe.steps.length === 0 ? ({t("recipes.noSteps")}
) : (-
- {recipe.steps.map((step) => (
+ {displayRecipe.steps.map((step) => (
-
{step.stepNumber}
diff --git a/meal-plan-frontend/src/i18n/locales/en/translation.json b/meal-plan-frontend/src/i18n/locales/en/translation.json
index f904297..4689d30 100644
--- a/meal-plan-frontend/src/i18n/locales/en/translation.json
+++ b/meal-plan-frontend/src/i18n/locales/en/translation.json
@@ -161,7 +161,16 @@
"noIngredients": "No ingredients listed.",
"noSteps": "No steps listed.",
"kcal": "{{count}} kcal",
- "minutes": "{{count}} min"
+ "minutes": "{{count}} min",
+ "recalculateCaloriesTitle": "Adjust calories",
+ "recalculateCaloriesHint": "Enter a target kcal — ingredient amounts will be scaled (catalog + AI).",
+ "targetCalories": "Target kcal",
+ "recalculate": "Recalculate",
+ "applyRecalculation": "Save changes",
+ "recalculatePreview": "Preview: {{previous}} → {{next}} kcal (target: {{target}})",
+ "recalculateUnlinkedHint": "{{count}} ingredient(s) not linked to the catalog — values may be less accurate.",
+ "recalculateCaloriesInvalid": "Enter calories between 50 and 10000 kcal.",
+ "recalculateCaloriesFailed": "Could not recalculate the recipe."
},
"manage": {
"title": "Manage Meals",
diff --git a/meal-plan-frontend/src/i18n/locales/pl/translation.json b/meal-plan-frontend/src/i18n/locales/pl/translation.json
index bbd66a6..ed88b30 100644
--- a/meal-plan-frontend/src/i18n/locales/pl/translation.json
+++ b/meal-plan-frontend/src/i18n/locales/pl/translation.json
@@ -165,7 +165,16 @@
"noIngredients": "Brak składników.",
"noSteps": "Brak kroków.",
"kcal": "{{count}} kcal",
- "minutes": "{{count}} min"
+ "minutes": "{{count}} min",
+ "recalculateCaloriesTitle": "Dopasuj kaloryczność",
+ "recalculateCaloriesHint": "Podaj docelową liczbę kcal — przeliczymy gramaturę składników (katalog + AI).",
+ "targetCalories": "Docelowe kcal",
+ "recalculate": "Przelicz",
+ "applyRecalculation": "Zapisz zmiany",
+ "recalculatePreview": "Podgląd: {{previous}} → {{next}} kcal (cel: {{target}})",
+ "recalculateUnlinkedHint": "{{count}} składnik(ów) bez powiązania z katalogiem — wartości mogą być mniej dokładne.",
+ "recalculateCaloriesInvalid": "Podaj kaloryczność między 50 a 10000 kcal.",
+ "recalculateCaloriesFailed": "Nie udało się przeliczyć przepisu."
},
"manage": {
"title": "Zarządzaj posiłkami",
diff --git a/meal-plan-frontend/src/pages/RecipeDetailPage.tsx b/meal-plan-frontend/src/pages/RecipeDetailPage.tsx
index be5d548..c93ccd6 100644
--- a/meal-plan-frontend/src/pages/RecipeDetailPage.tsx
+++ b/meal-plan-frontend/src/pages/RecipeDetailPage.tsx
@@ -1,3 +1,4 @@
+import { useState } from "react";
import { ArrowLeft } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
@@ -6,13 +7,16 @@ import { RecipeDetail } from "@/components/recipes/RecipeDetail";
import { Skeleton } from "@/components/ui/Skeleton";
import { ErrorState } from "@/components/ui/ErrorState";
import { extractApiError } from "@/api/axiosInstance";
+import type { RecipeDetail as RecipeDetailModel } from "@/types/recipe.types";
export function RecipeDetailPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const recipeId = Number(id);
- const { data: recipe, isLoading, isError, error, refetch } = useRecipe(recipeId);
+ const { data: fetchedRecipe, isLoading, isError, error, refetch } = useRecipe(recipeId);
+ const [recipe, setRecipe] = useState
(null); + const displayRecipe = recipe ?? fetchedRecipe ?? null; return ( @@ -41,7 +45,15 @@ export function RecipeDetailPage() { /> )} - {!isLoading && !isError && recipe &&); } diff --git a/meal-plan-frontend/src/types/recipe-management.types.ts b/meal-plan-frontend/src/types/recipe-management.types.ts index 21214ad..3234251 100644 --- a/meal-plan-frontend/src/types/recipe-management.types.ts +++ b/meal-plan-frontend/src/types/recipe-management.types.ts @@ -37,3 +37,18 @@ export interface RecipeMacroRecalcResult { recipesUpdated: number; unlinkedIngredientRows: number; } + +export interface RecalculateRecipeCaloriesResult { + recipeId: number; + previousCalories?: number | null; + targetCalories: number; + newCalories?: number | null; + newProtein?: number | null; + newFat?: number | null; + newCarbs?: number | null; + applied: boolean; + method: string; + unlinkedIngredientCount: number; + ingredients: import("./recipe.types").Ingredient[]; + recipe?: import("./recipe.types").RecipeDetail | null; +}} + {!isLoading && !isError && displayRecipe && ( + { + setRecipe(updated); + void refetch(); + }} + /> + )}