From beb08f2e6fb5988c5a99f21526b09775a51a31ab Mon Sep 17 00:00:00 2001 From: Piotr Kus Date: Wed, 24 Jun 2026 22:56:40 +0200 Subject: [PATCH] * Added recalculation --- .../Controllers/RecipeManagementController.cs | 27 ++- MealPlan.Api/Program.cs | 1 + .../public/i18n/en.json | 11 +- .../public/i18n/pl.json | 11 +- .../src/app/core/models/recipe.models.ts | 15 ++ .../services/recipe-management.service.ts | 12 + .../recipe-detail-page.component.ts | 2 +- .../recipes/recipe-detail-view.component.ts | 229 +++++++++++++----- .../src/api/recipe-management.api.ts | 14 +- .../src/components/recipes/RecipeDetail.tsx | 224 +++++++++++++++-- .../src/i18n/locales/en/translation.json | 11 +- .../src/i18n/locales/pl/translation.json | 11 +- .../src/pages/RecipeDetailPage.tsx | 16 +- .../src/types/recipe-management.types.ts | 15 ++ 14 files changed, 509 insertions(+), 90 deletions(-) diff --git a/MealPlan.Api/Controllers/RecipeManagementController.cs b/MealPlan.Api/Controllers/RecipeManagementController.cs index ca65e49..d9bf4a6 100644 --- a/MealPlan.Api/Controllers/RecipeManagementController.cs +++ b/MealPlan.Api/Controllers/RecipeManagementController.cs @@ -13,13 +13,16 @@ public class RecipeManagementController : ControllerBase { private readonly IRecipeManagementService _managementService; private readonly IRecipeExcelService _excelService; + private readonly IRecipeCalorieRecalculationService _calorieRecalculation; public RecipeManagementController( IRecipeManagementService managementService, - IRecipeExcelService excelService) + IRecipeExcelService excelService, + IRecipeCalorieRecalculationService calorieRecalculation) { _managementService = managementService; _excelService = excelService; + _calorieRecalculation = calorieRecalculation; } /// Creates a new recipe with ingredients and steps. @@ -90,4 +93,26 @@ public class RecipeManagementController : ControllerBase var result = await _managementService.RecalculateAllMacrosFromCatalogAsync(ct); return Ok(result); } + + /// Scales or AI-adjusts ingredient amounts to match a calorie target. + [HttpPost("{id:int}/recalculate-calories")] + [ProducesResponseType(typeof(RecalculateRecipeCaloriesResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task RecalculateCalories( + int id, + [FromBody] RecalculateRecipeCaloriesRequestDto request, + CancellationToken ct) + { + try + { + var result = await _calorieRecalculation.RecalculateAsync(id, request, ct); + return result is null + ? NotFound(new { error = $"Recipe {id} was not found." }) + : Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } } diff --git a/MealPlan.Api/Program.cs b/MealPlan.Api/Program.cs index dd8fdb0..1486f07 100644 --- a/MealPlan.Api/Program.cs +++ b/MealPlan.Api/Program.cs @@ -69,6 +69,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/meal-plan-frontend-angular/public/i18n/en.json b/meal-plan-frontend-angular/public/i18n/en.json index 0c5929a..90cfb44 100644 --- a/meal-plan-frontend-angular/public/i18n/en.json +++ b/meal-plan-frontend-angular/public/i18n/en.json @@ -160,7 +160,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-angular/public/i18n/pl.json b/meal-plan-frontend-angular/public/i18n/pl.json index 0351f65..d171d1f 100644 --- a/meal-plan-frontend-angular/public/i18n/pl.json +++ b/meal-plan-frontend-angular/public/i18n/pl.json @@ -164,7 +164,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-angular/src/app/core/models/recipe.models.ts b/meal-plan-frontend-angular/src/app/core/models/recipe.models.ts index b482f36..eeab4df 100644 --- a/meal-plan-frontend-angular/src/app/core/models/recipe.models.ts +++ b/meal-plan-frontend-angular/src/app/core/models/recipe.models.ts @@ -98,3 +98,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: Ingredient[]; + recipe?: RecipeDetail | null; +} diff --git a/meal-plan-frontend-angular/src/app/core/services/recipe-management.service.ts b/meal-plan-frontend-angular/src/app/core/services/recipe-management.service.ts index 3afc71e..eb8debd 100644 --- a/meal-plan-frontend-angular/src/app/core/services/recipe-management.service.ts +++ b/meal-plan-frontend-angular/src/app/core/services/recipe-management.service.ts @@ -6,6 +6,7 @@ import { RecipeDetail, RecipeImportResult, RecipeMacroRecalcResult, + RecalculateRecipeCaloriesResult, } from '../models/recipe.models'; @Injectable({ providedIn: 'root' }) @@ -34,6 +35,17 @@ export class RecipeManagementService { return this.http.post('/api/recipes/recalculate-macros', {}); } + recalculateRecipeCalories( + recipeId: number, + targetCalories: number, + apply = false, + ): Observable { + return this.http.post( + `/api/recipes/${recipeId}/recalculate-calories`, + { targetCalories, apply }, + ); + } + private toPayload(input: CreateRecipeInput) { return { name: input.name, diff --git a/meal-plan-frontend-angular/src/app/features/recipe-detail/recipe-detail-page.component.ts b/meal-plan-frontend-angular/src/app/features/recipe-detail/recipe-detail-page.component.ts index 9eb34f0..8b382f4 100644 --- a/meal-plan-frontend-angular/src/app/features/recipe-detail/recipe-detail-page.component.ts +++ b/meal-plan-frontend-angular/src/app/features/recipe-detail/recipe-detail-page.component.ts @@ -37,7 +37,7 @@ import { extractApiError } from '../../core/utils/api-error.util'; } @if (!loading() && !error() && recipe()) { - + } `, diff --git a/meal-plan-frontend-angular/src/app/features/recipes/recipe-detail-view.component.ts b/meal-plan-frontend-angular/src/app/features/recipes/recipe-detail-view.component.ts index 98d1b27..bd4cbec 100644 --- a/meal-plan-frontend-angular/src/app/features/recipes/recipe-detail-view.component.ts +++ b/meal-plan-frontend-angular/src/app/features/recipes/recipe-detail-view.component.ts @@ -1,7 +1,11 @@ -import { Component, Input, computed, signal } from '@angular/core'; -import { TranslatePipe } from '@ngx-translate/core'; -import { RecipeDetail } from '../../core/models/recipe.models'; +import { Component, EventEmitter, Input, OnChanges, Output, computed, inject, signal } from '@angular/core'; +import { NgClass } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { RecipeManagementService } from '../../core/services/recipe-management.service'; +import { RecipeDetail, RecalculateRecipeCaloriesResult } from '../../core/models/recipe.models'; import { CategoryBadgeComponent } from '../../shared/components/category-badge.component'; +import { extractApiError } from '../../core/utils/api-error.util'; import { nextSort, sortBy, sortIndicator, type SortDirection } from '../../core/utils/sort.util'; type IngredientSortColumn = 'name' | 'amount'; @@ -9,71 +13,84 @@ type IngredientSortColumn = 'name' | 'amount'; @Component({ selector: 'app-recipe-detail-view', standalone: true, - imports: [TranslatePipe, CategoryBadgeComponent], + imports: [NgClass, FormsModule, TranslatePipe, CategoryBadgeComponent], template: `
- -

{{ 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 }}

+
+ + + @if (showingPreview()) { + + } +
+ @if (showingPreview() && preview()) { +

+ {{ + '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 }} -

-
+ } + @if (error()) { + + }

{{ 'recipes.ingredients' | translate }}

- @if (recipe.ingredients.length === 0) { + @if (displayRecipe().ingredients.length === 0) {

{{ 'recipes.noIngredients' | translate }}

} @else {
@@ -89,8 +106,20 @@ type IngredientSortColumn = 'name' | 'amount'; @for (ing of sortedIngredients(); track ing.id) {
  • {{ ing.name }} - + {{ formatAmount(ing.amountGrams, ing.unit) }} + @if (amountChanged(ing.id, ing.amountGrams)) { + + {{ formatAmount(originalAmounts().get(ing.id) ?? null, ing.unit) }} + + }
  • } @@ -101,11 +130,11 @@ type IngredientSortColumn = 'name' | 'amount';

    {{ '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) {
    1. {{ 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}

    {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} + +
    • + ); + })}
    )} @@ -119,11 +293,11 @@ export function RecipeDetail({ recipe }: RecipeDetailProps) {

    {t("recipes.preparation")}

    - {recipe.steps.length === 0 ? ( + {displayRecipe.steps.length === 0 ? (

    {t("recipes.noSteps")}

    ) : (
      - {recipe.steps.map((step) => ( + {displayRecipe.steps.map((step) => (
    1. {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 && } + {!isLoading && !isError && displayRecipe && ( + { + setRecipe(updated); + void refetch(); + }} + /> + )}
      ); } 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; +}