Compare commits
2 Commits
7b8dd3ff40
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2207bdde50 | |||
| beb08f2e6f |
@@ -13,13 +13,16 @@ public class RecipeManagementController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly IRecipeManagementService _managementService;
|
private readonly IRecipeManagementService _managementService;
|
||||||
private readonly IRecipeExcelService _excelService;
|
private readonly IRecipeExcelService _excelService;
|
||||||
|
private readonly IRecipeCalorieRecalculationService _calorieRecalculation;
|
||||||
|
|
||||||
public RecipeManagementController(
|
public RecipeManagementController(
|
||||||
IRecipeManagementService managementService,
|
IRecipeManagementService managementService,
|
||||||
IRecipeExcelService excelService)
|
IRecipeExcelService excelService,
|
||||||
|
IRecipeCalorieRecalculationService calorieRecalculation)
|
||||||
{
|
{
|
||||||
_managementService = managementService;
|
_managementService = managementService;
|
||||||
_excelService = excelService;
|
_excelService = excelService;
|
||||||
|
_calorieRecalculation = calorieRecalculation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Creates a new recipe with ingredients and steps.</summary>
|
/// <summary>Creates a new recipe with ingredients and steps.</summary>
|
||||||
@@ -90,4 +93,26 @@ public class RecipeManagementController : ControllerBase
|
|||||||
var result = await _managementService.RecalculateAllMacrosFromCatalogAsync(ct);
|
var result = await _managementService.RecalculateAllMacrosFromCatalogAsync(ct);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Scales or AI-adjusts ingredient amounts to match a calorie target.</summary>
|
||||||
|
[HttpPost("{id:int}/recalculate-calories")]
|
||||||
|
[ProducesResponseType(typeof(RecalculateRecipeCaloriesResultDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
MealPlan.Api/DTOs/Recipe/RecalculateRecipeCaloriesDto.cs
Normal file
25
MealPlan.Api/DTOs/Recipe/RecalculateRecipeCaloriesDto.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
namespace MealPlan.Api.DTOs.Recipe;
|
||||||
|
|
||||||
|
public class RecalculateRecipeCaloriesRequestDto
|
||||||
|
{
|
||||||
|
public int TargetCalories { get; set; }
|
||||||
|
public int CalorieToleranceKcal { get; set; } = 10;
|
||||||
|
public bool Apply { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RecalculateRecipeCaloriesResultDto
|
||||||
|
{
|
||||||
|
public int RecipeId { get; set; }
|
||||||
|
public int? PreviousCalories { get; set; }
|
||||||
|
public int TargetCalories { get; set; }
|
||||||
|
public int? NewCalories { get; set; }
|
||||||
|
public decimal? NewProtein { get; set; }
|
||||||
|
public decimal? NewFat { get; set; }
|
||||||
|
public decimal? NewCarbs { get; set; }
|
||||||
|
public bool Applied { get; set; }
|
||||||
|
/// <summary>catalog | ai | catalog+ai</summary>
|
||||||
|
public string Method { get; set; } = string.Empty;
|
||||||
|
public int UnlinkedIngredientCount { get; set; }
|
||||||
|
public List<IngredientDto> Ingredients { get; set; } = new();
|
||||||
|
public RecipeDetailDto? Recipe { get; set; }
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ builder.Services.AddScoped<ITokenService, TokenService>();
|
|||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
builder.Services.AddScoped<IRecipeService, RecipeService>();
|
builder.Services.AddScoped<IRecipeService, RecipeService>();
|
||||||
builder.Services.AddScoped<IRecipeManagementService, RecipeManagementService>();
|
builder.Services.AddScoped<IRecipeManagementService, RecipeManagementService>();
|
||||||
|
builder.Services.AddScoped<IRecipeCalorieRecalculationService, RecipeCalorieRecalculationService>();
|
||||||
builder.Services.AddScoped<IRecipeExcelService, RecipeExcelService>();
|
builder.Services.AddScoped<IRecipeExcelService, RecipeExcelService>();
|
||||||
builder.Services.AddScoped<IDietTrackingService, DietTrackingService>();
|
builder.Services.AddScoped<IDietTrackingService, DietTrackingService>();
|
||||||
builder.Services.AddScoped<IIngredientCatalogService, IngredientCatalogService>();
|
builder.Services.AddScoped<IIngredientCatalogService, IngredientCatalogService>();
|
||||||
|
|||||||
250
MealPlan.Api/Services/RecipeCalorieRecalculationService.cs
Normal file
250
MealPlan.Api/Services/RecipeCalorieRecalculationService.cs
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using MealPlan.Api.Data;
|
||||||
|
using MealPlan.Api.DTOs.Recipe;
|
||||||
|
using MealPlan.Api.Models;
|
||||||
|
using MealPlan.Api.Services.AI;
|
||||||
|
using MealPlan.Api.Services.Generators;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace MealPlan.Api.Services;
|
||||||
|
|
||||||
|
public interface IRecipeCalorieRecalculationService
|
||||||
|
{
|
||||||
|
Task<RecalculateRecipeCaloriesResultDto?> RecalculateAsync(
|
||||||
|
int recipeId,
|
||||||
|
RecalculateRecipeCaloriesRequestDto request,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RecipeCalorieRecalculationService : IRecipeCalorieRecalculationService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IOpenAiChatService _openAi;
|
||||||
|
private readonly IRecipeManagementService _recipeManagement;
|
||||||
|
|
||||||
|
public RecipeCalorieRecalculationService(
|
||||||
|
AppDbContext db,
|
||||||
|
IOpenAiChatService openAi,
|
||||||
|
IRecipeManagementService recipeManagement)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_openAi = openAi;
|
||||||
|
_recipeManagement = recipeManagement;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RecalculateRecipeCaloriesResultDto?> RecalculateAsync(
|
||||||
|
int recipeId,
|
||||||
|
RecalculateRecipeCaloriesRequestDto request,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (request.TargetCalories is < 50 or > 10000)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Target calories must be between 50 and 10000.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var recipe = await _db.Recipes.AsNoTracking()
|
||||||
|
.Include(r => r.Ingredients)
|
||||||
|
.Include(r => r.Steps)
|
||||||
|
.FirstOrDefaultAsync(r => r.Id == recipeId && r.IsActive, ct);
|
||||||
|
if (recipe is null) return null;
|
||||||
|
|
||||||
|
var tolerance = request.CalorieToleranceKcal > 0 ? request.CalorieToleranceKcal : 10;
|
||||||
|
var workingIngredients = recipe.Ingredients
|
||||||
|
.OrderBy(i => i.SortOrder)
|
||||||
|
.Select(CloneIngredient)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var methods = new List<string>();
|
||||||
|
var currentTotals = await ComputeTotalsAsync(workingIngredients, ct);
|
||||||
|
var scaleFactor = RecipeMacroCalculator.ResolveScaleFactor(
|
||||||
|
currentTotals?.Calories ?? recipe.Calories ?? 0,
|
||||||
|
request.TargetCalories,
|
||||||
|
tolerance);
|
||||||
|
|
||||||
|
if (scaleFactor != 1m)
|
||||||
|
{
|
||||||
|
RecipeMacroCalculator.ScaleIngredientAmounts(
|
||||||
|
workingIngredients,
|
||||||
|
scaleFactor,
|
||||||
|
tolerance,
|
||||||
|
request.TargetCalories,
|
||||||
|
currentTotals);
|
||||||
|
methods.Add("catalog");
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTotals = await ComputeTotalsAsync(workingIngredients, ct);
|
||||||
|
var needsAi = _openAi.IsConfigured &&
|
||||||
|
(currentTotals is null ||
|
||||||
|
Math.Abs(currentTotals.Value.Calories - request.TargetCalories) > tolerance ||
|
||||||
|
RecipeMacroCalculator.CountUnlinkedMacroIngredients(workingIngredients) > 0);
|
||||||
|
|
||||||
|
if (needsAi)
|
||||||
|
{
|
||||||
|
await ApplyAiAdjustedAmountsAsync(recipe, workingIngredients, request.TargetCalories, ct);
|
||||||
|
methods.Add("ai");
|
||||||
|
currentTotals = await ComputeTotalsAsync(workingIngredients, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTotals is null && recipe.Calories is > 0)
|
||||||
|
{
|
||||||
|
var fallbackScale = RecipeMacroCalculator.ResolveScaleFactor(
|
||||||
|
recipe.Calories.Value,
|
||||||
|
request.TargetCalories,
|
||||||
|
tolerance);
|
||||||
|
if (fallbackScale != 1m)
|
||||||
|
{
|
||||||
|
foreach (var ing in workingIngredients)
|
||||||
|
{
|
||||||
|
if (ing.AmountGrams is not > 0 || RecipeMacroCalculator.IsSpiceUnit(ing.Unit)) continue;
|
||||||
|
ing.AmountGrams = Math.Round(ing.AmountGrams.Value * fallbackScale, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!methods.Contains("catalog")) methods.Add("catalog");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTotals = await ComputeTotalsAsync(workingIngredients, ct);
|
||||||
|
var ingredientDtos = MapIngredients(workingIngredients);
|
||||||
|
var unlinked = RecipeMacroCalculator.CountUnlinkedMacroIngredients(workingIngredients);
|
||||||
|
|
||||||
|
RecipeDetailDto? savedRecipe = null;
|
||||||
|
if (request.Apply)
|
||||||
|
{
|
||||||
|
var updateDto = new CreateRecipeDto
|
||||||
|
{
|
||||||
|
Name = recipe.Name,
|
||||||
|
MealCategory = recipe.MealCategory,
|
||||||
|
Calories = currentTotals?.Calories ?? request.TargetCalories,
|
||||||
|
Protein = currentTotals?.Protein,
|
||||||
|
Fat = currentTotals?.Fat,
|
||||||
|
Carbs = currentTotals?.Carbs,
|
||||||
|
PrepTimeMinutes = recipe.PrepTimeMinutes,
|
||||||
|
Ingredients = workingIngredients.Select((i, index) => new CreateIngredientDto
|
||||||
|
{
|
||||||
|
Name = i.Name,
|
||||||
|
AmountGrams = i.AmountGrams,
|
||||||
|
Unit = i.Unit,
|
||||||
|
SortOrder = index,
|
||||||
|
CatalogItemId = i.CatalogItemId,
|
||||||
|
}).ToList(),
|
||||||
|
Steps = recipe.Steps
|
||||||
|
.OrderBy(s => s.StepNumber)
|
||||||
|
.Select(s => new CreateRecipeStepDto
|
||||||
|
{
|
||||||
|
StepNumber = s.StepNumber,
|
||||||
|
Description = s.Description,
|
||||||
|
}).ToList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
savedRecipe = await _recipeManagement.UpdateRecipeAsync(recipeId, updateDto, ct);
|
||||||
|
if (savedRecipe is null) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RecalculateRecipeCaloriesResultDto
|
||||||
|
{
|
||||||
|
RecipeId = recipeId,
|
||||||
|
PreviousCalories = recipe.Calories,
|
||||||
|
TargetCalories = request.TargetCalories,
|
||||||
|
NewCalories = currentTotals?.Calories ?? savedRecipe?.Calories,
|
||||||
|
NewProtein = currentTotals?.Protein ?? savedRecipe?.Protein,
|
||||||
|
NewFat = currentTotals?.Fat ?? savedRecipe?.Fat,
|
||||||
|
NewCarbs = currentTotals?.Carbs ?? savedRecipe?.Carbs,
|
||||||
|
Applied = request.Apply && savedRecipe is not null,
|
||||||
|
Method = methods.Count == 0 ? "none" : string.Join('+', methods),
|
||||||
|
UnlinkedIngredientCount = unlinked,
|
||||||
|
Ingredients = savedRecipe?.Ingredients ?? ingredientDtos,
|
||||||
|
Recipe = savedRecipe,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyAiAdjustedAmountsAsync(
|
||||||
|
Recipe source,
|
||||||
|
IList<Ingredient> ingredients,
|
||||||
|
int targetCalories,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var system = """
|
||||||
|
You adjust recipe ingredient amounts to match a calorie target for a meal planning app.
|
||||||
|
Respond ONLY JSON:
|
||||||
|
{"ingredients":[{"name":"exact ingredient name","amountGrams":100,"unit":null}]}
|
||||||
|
Rules:
|
||||||
|
- Keep every ingredient from the input list (same names).
|
||||||
|
- Change amountGrams for macro ingredients so the recipe totals about targetCalories kcal.
|
||||||
|
- Use realistic gram amounts. Round to one decimal.
|
||||||
|
- Spices with unit "do smaku" / "to taste" keep null amountGrams.
|
||||||
|
- Do not add or remove ingredients.
|
||||||
|
""";
|
||||||
|
|
||||||
|
var user = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
recipeName = source.Name,
|
||||||
|
targetCalories,
|
||||||
|
currentCalories = source.Calories,
|
||||||
|
ingredients = ingredients.Select(i => new
|
||||||
|
{
|
||||||
|
i.Name,
|
||||||
|
i.AmountGrams,
|
||||||
|
i.Unit,
|
||||||
|
i.CatalogItemId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
var json = await _openAi.CompleteJsonAsync(system, user, OpenAiUseCase.Recipe, ct);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
if (!doc.RootElement.TryGetProperty("ingredients", out var ingredientsEl) ||
|
||||||
|
ingredientsEl.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("AI did not return adjusted ingredients. Try again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var byName = ingredients.ToDictionary(i => i.Name.Trim(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var item in ingredientsEl.EnumerateArray())
|
||||||
|
{
|
||||||
|
var name = item.TryGetProperty("name", out var nameEl)
|
||||||
|
? AiRecipeJsonParser.GetString(nameEl)
|
||||||
|
: null;
|
||||||
|
if (string.IsNullOrWhiteSpace(name) || !byName.TryGetValue(name.Trim(), out var target)) continue;
|
||||||
|
|
||||||
|
if (item.TryGetProperty("amountGrams", out var gramsEl))
|
||||||
|
{
|
||||||
|
target.AmountGrams = AiRecipeJsonParser.GetNullableDecimal(gramsEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.TryGetProperty("unit", out var unitEl) && unitEl.ValueKind != JsonValueKind.Null)
|
||||||
|
{
|
||||||
|
target.Unit = AiRecipeJsonParser.GetString(unitEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<MacroTotals?> ComputeTotalsAsync(IEnumerable<Ingredient> ingredients, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await RecipeMacroCalculator.ComputeFromCatalogAsync(
|
||||||
|
_db,
|
||||||
|
ingredients.Select(i => (i.AmountGrams, i.Unit, i.CatalogItemId)),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Ingredient CloneIngredient(Ingredient source) => new()
|
||||||
|
{
|
||||||
|
Id = source.Id,
|
||||||
|
Name = source.Name,
|
||||||
|
AmountGrams = source.AmountGrams,
|
||||||
|
Unit = source.Unit,
|
||||||
|
SortOrder = source.SortOrder,
|
||||||
|
CatalogItemId = source.CatalogItemId,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static List<IngredientDto> MapIngredients(IEnumerable<Ingredient> ingredients) =>
|
||||||
|
ingredients
|
||||||
|
.OrderBy(i => i.SortOrder)
|
||||||
|
.Select(i => new IngredientDto
|
||||||
|
{
|
||||||
|
Id = i.Id,
|
||||||
|
Name = i.Name,
|
||||||
|
AmountGrams = i.AmountGrams,
|
||||||
|
Unit = i.Unit,
|
||||||
|
SortOrder = i.SortOrder,
|
||||||
|
CatalogItemId = i.CatalogItemId,
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
83
MealPlan.Api/Services/RecipeMacroCalculator.cs
Normal file
83
MealPlan.Api/Services/RecipeMacroCalculator.cs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
using MealPlan.Api.Data;
|
||||||
|
using MealPlan.Api.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace MealPlan.Api.Services;
|
||||||
|
|
||||||
|
public readonly record struct MacroTotals(int Calories, decimal Protein, decimal Fat, decimal Carbs);
|
||||||
|
|
||||||
|
public static class RecipeMacroCalculator
|
||||||
|
{
|
||||||
|
public static bool IsSpiceUnit(string? unit)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(unit)) return false;
|
||||||
|
var u = unit.Trim().ToLowerInvariant();
|
||||||
|
return u is "do smaku" or "optional" or "opcjonalnie" or "szczypta" or "to taste";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsQuantifiedMacroIngredient(decimal? amountGrams, string? unit, int? catalogItemId) =>
|
||||||
|
catalogItemId.HasValue && amountGrams is > 0 && !IsSpiceUnit(unit);
|
||||||
|
|
||||||
|
public static async Task<MacroTotals?> ComputeFromCatalogAsync(
|
||||||
|
AppDbContext db,
|
||||||
|
IEnumerable<(decimal? AmountGrams, string? Unit, int? CatalogItemId)> ingredients,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var rows = ingredients.ToList();
|
||||||
|
var ids = rows
|
||||||
|
.Where(i => IsQuantifiedMacroIngredient(i.AmountGrams, i.Unit, i.CatalogItemId))
|
||||||
|
.Select(i => i.CatalogItemId!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
if (ids.Count == 0) return null;
|
||||||
|
|
||||||
|
var catalog = await db.IngredientNutritionCatalog.AsNoTracking()
|
||||||
|
.Where(i => ids.Contains(i.Id) && i.IsActive)
|
||||||
|
.ToDictionaryAsync(i => i.Id, ct);
|
||||||
|
|
||||||
|
int calories = 0;
|
||||||
|
decimal protein = 0, fat = 0, carbs = 0;
|
||||||
|
foreach (var ing in rows)
|
||||||
|
{
|
||||||
|
if (!IsQuantifiedMacroIngredient(ing.AmountGrams, ing.Unit, ing.CatalogItemId)) continue;
|
||||||
|
if (!catalog.TryGetValue(ing.CatalogItemId!.Value, out var cat)) continue;
|
||||||
|
|
||||||
|
var scale = ing.AmountGrams!.Value / 100m;
|
||||||
|
calories += (int)Math.Round(cat.CaloriesPer100G * scale);
|
||||||
|
protein += cat.ProteinGPer100G * scale;
|
||||||
|
fat += cat.FatGPer100G * scale;
|
||||||
|
carbs += cat.CarbsGPer100G * scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (calories <= 0) return null;
|
||||||
|
return new MacroTotals(calories, Math.Round(protein, 2), Math.Round(fat, 2), Math.Round(carbs, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static decimal ResolveScaleFactor(int currentCalories, int targetCalories, int toleranceKcal)
|
||||||
|
{
|
||||||
|
if (currentCalories <= 0 || targetCalories <= 0) return 1m;
|
||||||
|
if (Math.Abs(currentCalories - targetCalories) <= toleranceKcal) return 1m;
|
||||||
|
return Math.Clamp((decimal)targetCalories / currentCalories, 0.25m, 4m);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ScaleIngredientAmounts(
|
||||||
|
IList<Ingredient> ingredients,
|
||||||
|
decimal scaleFactor,
|
||||||
|
int toleranceKcal,
|
||||||
|
int targetCalories,
|
||||||
|
MacroTotals? currentTotals)
|
||||||
|
{
|
||||||
|
if (scaleFactor == 1m) return;
|
||||||
|
if (currentTotals is null || currentTotals.Value.Calories <= 0) return;
|
||||||
|
if (Math.Abs(currentTotals.Value.Calories - targetCalories) <= toleranceKcal) return;
|
||||||
|
|
||||||
|
foreach (var ing in ingredients)
|
||||||
|
{
|
||||||
|
if (!IsQuantifiedMacroIngredient(ing.AmountGrams, ing.Unit, ing.CatalogItemId)) continue;
|
||||||
|
ing.AmountGrams = Math.Round(ing.AmountGrams!.Value * scaleFactor, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int CountUnlinkedMacroIngredients(IEnumerable<Ingredient> ingredients) =>
|
||||||
|
ingredients.Count(i => i.AmountGrams is > 0 && !IsSpiceUnit(i.Unit) && !i.CatalogItemId.HasValue);
|
||||||
|
}
|
||||||
@@ -160,7 +160,16 @@
|
|||||||
"noIngredients": "No ingredients listed.",
|
"noIngredients": "No ingredients listed.",
|
||||||
"noSteps": "No steps listed.",
|
"noSteps": "No steps listed.",
|
||||||
"kcal": "{{count}} kcal",
|
"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": {
|
"manage": {
|
||||||
"title": "Manage Meals",
|
"title": "Manage Meals",
|
||||||
|
|||||||
@@ -164,7 +164,16 @@
|
|||||||
"noIngredients": "Brak składników.",
|
"noIngredients": "Brak składników.",
|
||||||
"noSteps": "Brak kroków.",
|
"noSteps": "Brak kroków.",
|
||||||
"kcal": "{{count}} kcal",
|
"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": {
|
"manage": {
|
||||||
"title": "Zarządzaj posiłkami",
|
"title": "Zarządzaj posiłkami",
|
||||||
|
|||||||
@@ -98,3 +98,18 @@ export interface RecipeMacroRecalcResult {
|
|||||||
recipesUpdated: number;
|
recipesUpdated: number;
|
||||||
unlinkedIngredientRows: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
RecipeDetail,
|
RecipeDetail,
|
||||||
RecipeImportResult,
|
RecipeImportResult,
|
||||||
RecipeMacroRecalcResult,
|
RecipeMacroRecalcResult,
|
||||||
|
RecalculateRecipeCaloriesResult,
|
||||||
} from '../models/recipe.models';
|
} from '../models/recipe.models';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -34,6 +35,17 @@ export class RecipeManagementService {
|
|||||||
return this.http.post<RecipeMacroRecalcResult>('/api/recipes/recalculate-macros', {});
|
return this.http.post<RecipeMacroRecalcResult>('/api/recipes/recalculate-macros', {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recalculateRecipeCalories(
|
||||||
|
recipeId: number,
|
||||||
|
targetCalories: number,
|
||||||
|
apply = false,
|
||||||
|
): Observable<RecalculateRecipeCaloriesResult> {
|
||||||
|
return this.http.post<RecalculateRecipeCaloriesResult>(
|
||||||
|
`/api/recipes/${recipeId}/recalculate-calories`,
|
||||||
|
{ targetCalories, apply },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private toPayload(input: CreateRecipeInput) {
|
private toPayload(input: CreateRecipeInput) {
|
||||||
return {
|
return {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import { extractApiError } from '../../core/utils/api-error.util';
|
|||||||
}
|
}
|
||||||
|
|
||||||
@if (!loading() && !error() && recipe()) {
|
@if (!loading() && !error() && recipe()) {
|
||||||
<app-recipe-detail-view [recipe]="recipe()!" />
|
<app-recipe-detail-view [recipe]="recipe()!" (recipeUpdated)="recipe.set($event)" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Component, Input, computed, signal } from '@angular/core';
|
import { Component, EventEmitter, Input, OnChanges, Output, computed, inject, signal } from '@angular/core';
|
||||||
import { TranslatePipe } from '@ngx-translate/core';
|
import { NgClass } from '@angular/common';
|
||||||
import { RecipeDetail } from '../../core/models/recipe.models';
|
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 { 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';
|
import { nextSort, sortBy, sortIndicator, type SortDirection } from '../../core/utils/sort.util';
|
||||||
|
|
||||||
type IngredientSortColumn = 'name' | 'amount';
|
type IngredientSortColumn = 'name' | 'amount';
|
||||||
@@ -9,71 +13,84 @@ type IngredientSortColumn = 'name' | 'amount';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-recipe-detail-view',
|
selector: 'app-recipe-detail-view',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [TranslatePipe, CategoryBadgeComponent],
|
imports: [NgClass, FormsModule, TranslatePipe, CategoryBadgeComponent],
|
||||||
template: `
|
template: `
|
||||||
<article class="space-y-8">
|
<article class="space-y-8">
|
||||||
<header class="space-y-3">
|
<header class="space-y-3">
|
||||||
<app-category-badge [category]="recipe.mealCategory" />
|
<app-category-badge [category]="displayRecipe().mealCategory" />
|
||||||
<h1 class="text-3xl font-extrabold tracking-tight">{{ recipe.name }}</h1>
|
<h1 class="text-3xl font-extrabold tracking-tight">{{ displayRecipe().name }}</h1>
|
||||||
@if (recipe.prepTimeMinutes != null) {
|
@if (displayRecipe().prepTimeMinutes != null) {
|
||||||
<p class="inline-flex items-center gap-1.5 text-sm text-slate-500 dark:text-slate-400">
|
<p class="inline-flex items-center gap-1.5 text-sm text-slate-500 dark:text-slate-400">
|
||||||
<span aria-hidden="true">⏱</span>
|
<span aria-hidden="true">⏱</span>
|
||||||
{{ 'recipes.prepTime' | translate: { count: recipe.prepTimeMinutes } }}
|
{{ 'recipes.prepTime' | translate: { count: displayRecipe().prepTimeMinutes } }}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<section class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-900">
|
@for (macro of macroCards(); track macro.label) {
|
||||||
<p class="text-2xl font-extrabold text-orange-500">
|
<div
|
||||||
{{ recipe.calories ?? '—' }}
|
class="rounded-2xl border px-4 py-3 text-center"
|
||||||
@if (recipe.calories != null) {
|
[ngClass]="
|
||||||
<span class="text-sm font-semibold"> kcal</span>
|
macro.highlight
|
||||||
|
? 'border-brand-300 bg-brand-50 dark:border-brand-700 dark:bg-brand-950/30'
|
||||||
|
: 'border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p class="text-2xl font-extrabold" [class]="macro.accent">
|
||||||
|
{{ macro.value ?? '—' }}
|
||||||
|
@if (macro.value != null) {
|
||||||
|
<span class="text-sm font-semibold"> {{ macro.suffix }}</span>
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||||
{{ 'recipes.calories' | translate }}
|
{{ macro.label | translate }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-900">
|
}
|
||||||
<p class="text-2xl font-extrabold text-brand-600">
|
</section>
|
||||||
{{ recipe.protein ?? '—' }}
|
|
||||||
@if (recipe.protein != null) {
|
<section class="card space-y-3 p-4">
|
||||||
<span class="text-sm font-semibold"> g</span>
|
<h2 class="text-sm font-bold">{{ 'recipes.recalculateCaloriesTitle' | translate }}</h2>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">{{ 'recipes.recalculateCaloriesHint' | translate }}</p>
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<label class="block text-sm">
|
||||||
|
{{ 'recipes.targetCalories' | translate }}
|
||||||
|
<input type="number" min="50" max="10000" class="input mt-1 w-36" [(ngModel)]="targetCalories" (ngModelChange)="preview.set(null)" />
|
||||||
|
</label>
|
||||||
|
<button type="button" class="btn-secondary" [disabled]="pending() || applyPending()" (click)="runRecalculate(false)">
|
||||||
|
↻ {{ 'recipes.recalculate' | translate }}
|
||||||
|
</button>
|
||||||
|
@if (showingPreview()) {
|
||||||
|
<button type="button" class="btn-primary" [disabled]="pending() || applyPending()" (click)="runRecalculate(true)">
|
||||||
|
{{ 'recipes.applyRecalculation' | translate }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (showingPreview() && preview()) {
|
||||||
|
<p class="text-sm text-brand-700 dark:text-brand-300">
|
||||||
|
{{
|
||||||
|
'recipes.recalculatePreview'
|
||||||
|
| translate: {
|
||||||
|
previous: preview()!.previousCalories ?? '—',
|
||||||
|
next: preview()!.newCalories ?? '—',
|
||||||
|
target: preview()!.targetCalories,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
@if (preview()!.unlinkedIngredientCount > 0) {
|
||||||
|
{{ 'recipes.recalculateUnlinkedHint' | translate: { count: preview()!.unlinkedIngredientCount } }}
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
|
||||||
{{ 'recipes.protein' | translate }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-900">
|
|
||||||
<p class="text-2xl font-extrabold text-amber-500">
|
|
||||||
{{ recipe.fat ?? '—' }}
|
|
||||||
@if (recipe.fat != null) {
|
|
||||||
<span class="text-sm font-semibold"> g</span>
|
|
||||||
}
|
}
|
||||||
</p>
|
@if (error()) {
|
||||||
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
<p role="alert" class="text-sm text-red-600 dark:text-red-400">{{ error() }}</p>
|
||||||
{{ 'recipes.fat' | translate }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-900">
|
|
||||||
<p class="text-2xl font-extrabold text-sky-500">
|
|
||||||
{{ recipe.carbs ?? '—' }}
|
|
||||||
@if (recipe.carbs != null) {
|
|
||||||
<span class="text-sm font-semibold"> g</span>
|
|
||||||
}
|
}
|
||||||
</p>
|
|
||||||
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
|
||||||
{{ 'recipes.carbs' | translate }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="grid gap-8 lg:grid-cols-[1fr_1.4fr]">
|
<div class="grid gap-8 lg:grid-cols-[1fr_1.4fr]">
|
||||||
<section>
|
<section>
|
||||||
<h2 class="text-lg font-bold">{{ 'recipes.ingredients' | translate }}</h2>
|
<h2 class="text-lg font-bold">{{ 'recipes.ingredients' | translate }}</h2>
|
||||||
@if (recipe.ingredients.length === 0) {
|
@if (displayRecipe().ingredients.length === 0) {
|
||||||
<p class="mt-3 text-sm text-slate-500">{{ 'recipes.noIngredients' | translate }}</p>
|
<p class="mt-3 text-sm text-slate-500">{{ 'recipes.noIngredients' | translate }}</p>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="mt-4 overflow-hidden rounded-2xl border border-slate-200 dark:border-slate-800">
|
<div class="mt-4 overflow-hidden rounded-2xl border border-slate-200 dark:border-slate-800">
|
||||||
@@ -89,8 +106,20 @@ type IngredientSortColumn = 'name' | 'amount';
|
|||||||
@for (ing of sortedIngredients(); track ing.id) {
|
@for (ing of sortedIngredients(); track ing.id) {
|
||||||
<li class="grid grid-cols-[1fr_auto] items-center px-4 py-3 text-sm">
|
<li class="grid grid-cols-[1fr_auto] items-center px-4 py-3 text-sm">
|
||||||
<span>{{ ing.name }}</span>
|
<span>{{ ing.name }}</span>
|
||||||
<span class="font-medium text-slate-500 dark:text-slate-400">
|
<span
|
||||||
|
class="font-medium"
|
||||||
|
[ngClass]="
|
||||||
|
amountChanged(ing.id, ing.amountGrams)
|
||||||
|
? 'text-brand-700 dark:text-brand-300'
|
||||||
|
: 'text-slate-500 dark:text-slate-400'
|
||||||
|
"
|
||||||
|
>
|
||||||
{{ formatAmount(ing.amountGrams, ing.unit) }}
|
{{ formatAmount(ing.amountGrams, ing.unit) }}
|
||||||
|
@if (amountChanged(ing.id, ing.amountGrams)) {
|
||||||
|
<span class="ml-2 text-xs text-slate-400 line-through">
|
||||||
|
{{ formatAmount(originalAmounts().get(ing.id) ?? null, ing.unit) }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -101,11 +130,11 @@ type IngredientSortColumn = 'name' | 'amount';
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 class="text-lg font-bold">{{ 'recipes.preparation' | translate }}</h2>
|
<h2 class="text-lg font-bold">{{ 'recipes.preparation' | translate }}</h2>
|
||||||
@if (recipe.steps.length === 0) {
|
@if (displayRecipe().steps.length === 0) {
|
||||||
<p class="mt-3 text-sm text-slate-500">{{ 'recipes.noSteps' | translate }}</p>
|
<p class="mt-3 text-sm text-slate-500">{{ 'recipes.noSteps' | translate }}</p>
|
||||||
} @else {
|
} @else {
|
||||||
<ol class="mt-4 space-y-4">
|
<ol class="mt-4 space-y-4">
|
||||||
@for (step of recipe.steps; track step.id) {
|
@for (step of displayRecipe().steps; track step.id) {
|
||||||
<li class="flex gap-4">
|
<li class="flex gap-4">
|
||||||
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-brand-600 text-sm font-bold text-white">
|
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-brand-600 text-sm font-bold text-white">
|
||||||
{{ step.stepNumber }}
|
{{ step.stepNumber }}
|
||||||
@@ -122,21 +151,73 @@ type IngredientSortColumn = 'name' | 'amount';
|
|||||||
</article>
|
</article>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class RecipeDetailViewComponent {
|
export class RecipeDetailViewComponent implements OnChanges {
|
||||||
|
private readonly management = inject(RecipeManagementService);
|
||||||
|
private readonly translate = inject(TranslateService);
|
||||||
|
|
||||||
@Input({ required: true }) recipe!: RecipeDetail;
|
@Input({ required: true }) recipe!: RecipeDetail;
|
||||||
|
@Output() recipeUpdated = new EventEmitter<RecipeDetail>();
|
||||||
|
|
||||||
|
readonly preview = signal<RecalculateRecipeCaloriesResult | null>(null);
|
||||||
|
readonly pending = signal(false);
|
||||||
|
readonly applyPending = signal(false);
|
||||||
|
readonly error = signal<string | null>(null);
|
||||||
|
targetCalories = '';
|
||||||
|
|
||||||
readonly ingredientSort = signal<{ column: IngredientSortColumn; direction: SortDirection }>({
|
readonly ingredientSort = signal<{ column: IngredientSortColumn; direction: SortDirection }>({
|
||||||
column: 'name',
|
column: 'name',
|
||||||
direction: 'asc',
|
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(() => {
|
readonly sortedIngredients = computed(() => {
|
||||||
const { column, direction } = this.ingredientSort();
|
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,
|
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 {
|
formatAmount(amount: number | null, unit: string | null): string {
|
||||||
if (amount == null && !unit) return '';
|
if (amount == null && !unit) return '';
|
||||||
if (amount == null) return unit ?? '';
|
if (amount == null) return unit ?? '';
|
||||||
@@ -144,6 +225,12 @@ export class RecipeDetailViewComponent {
|
|||||||
return unit ? `${display} ${unit}` : `${display} g`;
|
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 {
|
toggleIngredientSort(column: IngredientSortColumn): void {
|
||||||
this.ingredientSort.update((current) => nextSort(current, column));
|
this.ingredientSort.update((current) => nextSort(current, column));
|
||||||
}
|
}
|
||||||
@@ -151,4 +238,34 @@ export class RecipeDetailViewComponent {
|
|||||||
ingredientIndicator(column: IngredientSortColumn): string {
|
ingredientIndicator(column: IngredientSortColumn): string {
|
||||||
return sortIndicator(this.ingredientSort(), column);
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { api } from "./axiosInstance";
|
import { api } from "./axiosInstance";
|
||||||
import type { RecipeDetail } from "@/types/recipe.types";
|
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) {
|
function toPayload(input: CreateRecipeInput) {
|
||||||
return {
|
return {
|
||||||
@@ -55,3 +55,15 @@ export async function recalculateAllRecipeMacros(): Promise<RecipeMacroRecalcRes
|
|||||||
const { data } = await api.post<RecipeMacroRecalcResult>("/recipes/recalculate-macros");
|
const { data } = await api.post<RecipeMacroRecalcResult>("/recipes/recalculate-macros");
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function recalculateRecipeCalories(
|
||||||
|
recipeId: number,
|
||||||
|
targetCalories: number,
|
||||||
|
apply = false,
|
||||||
|
): Promise<RecalculateRecipeCaloriesResult> {
|
||||||
|
const { data } = await api.post<RecalculateRecipeCaloriesResult>(
|
||||||
|
`/recipes/${recipeId}/recalculate-calories`,
|
||||||
|
{ targetCalories, apply },
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Clock } from "lucide-react";
|
import { Clock, Loader2, RefreshCw, Save } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { recalculateRecipeCalories } from "@/api/recipe-management.api";
|
||||||
|
import { extractApiError } from "@/api/axiosInstance";
|
||||||
import { CategoryBadge } from "@/components/ui/CategoryBadge";
|
import { CategoryBadge } from "@/components/ui/CategoryBadge";
|
||||||
import type { RecipeDetail as RecipeDetailModel } from "@/types/recipe.types";
|
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";
|
import { nextSort, sortBy, sortIndicator, type SortDirection } from "@/utils/sort.util";
|
||||||
|
|
||||||
type IngredientSortColumn = "name" | "amount";
|
type IngredientSortColumn = "name" | "amount";
|
||||||
@@ -12,11 +15,18 @@ interface MacroProps {
|
|||||||
value: number | null;
|
value: number | null;
|
||||||
suffix: string;
|
suffix: string;
|
||||||
accent: string;
|
accent: string;
|
||||||
|
highlight?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Macro({ label, value, suffix, accent }: MacroProps) {
|
function Macro({ label, value, suffix, accent, highlight }: MacroProps) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-900">
|
<div
|
||||||
|
className={`rounded-2xl border px-4 py-3 text-center ${
|
||||||
|
highlight
|
||||||
|
? "border-brand-300 bg-brand-50 dark:border-brand-700 dark:bg-brand-950/30"
|
||||||
|
: "border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<p className={`text-2xl font-extrabold ${accent}`}>
|
<p className={`text-2xl font-extrabold ${accent}`}>
|
||||||
{value != null ? value : "—"}
|
{value != null ? value : "—"}
|
||||||
{value != null && <span className="text-sm font-semibold"> {suffix}</span>}
|
{value != null && <span className="text-sm font-semibold"> {suffix}</span>}
|
||||||
@@ -37,51 +47,198 @@ function formatAmount(amount: number | null, unit: string | null): string {
|
|||||||
|
|
||||||
interface RecipeDetailProps {
|
interface RecipeDetailProps {
|
||||||
recipe: RecipeDetailModel;
|
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 { t } = useTranslation();
|
||||||
const [ingredientSort, setIngredientSort] = useState<{
|
const [ingredientSort, setIngredientSort] = useState<{
|
||||||
column: IngredientSortColumn;
|
column: IngredientSortColumn;
|
||||||
direction: SortDirection;
|
direction: SortDirection;
|
||||||
}>({ column: "name", direction: "asc" });
|
}>({ column: "name", direction: "asc" });
|
||||||
|
const [targetCalories, setTargetCalories] = useState(
|
||||||
|
recipe.calories != null ? String(recipe.calories) : "",
|
||||||
|
);
|
||||||
|
const [preview, setPreview] = useState<RecalculateRecipeCaloriesResult | null>(null);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [applyPending, setApplyPending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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(
|
const sortedIngredients = useMemo(
|
||||||
() =>
|
() =>
|
||||||
sortBy(recipe.ingredients, ingredientSort.direction, (ing) =>
|
sortBy(displayRecipe.ingredients, ingredientSort.direction, (ing) =>
|
||||||
ingredientSort.column === "name" ? ing.name : ing.amountGrams,
|
ingredientSort.column === "name" ? ing.name : ing.amountGrams,
|
||||||
),
|
),
|
||||||
[recipe.ingredients, ingredientSort],
|
[displayRecipe.ingredients, ingredientSort],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleIngredientSort = (column: IngredientSortColumn) => {
|
const toggleIngredientSort = (column: IngredientSortColumn) => {
|
||||||
setIngredientSort((current) => nextSort(current, column));
|
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 (
|
return (
|
||||||
<article className="space-y-8">
|
<article className="space-y-8">
|
||||||
<header className="space-y-3">
|
<header className="space-y-3">
|
||||||
<CategoryBadge category={recipe.mealCategory} />
|
<CategoryBadge category={displayRecipe.mealCategory} />
|
||||||
<h1 className="text-3xl font-extrabold tracking-tight">{recipe.name}</h1>
|
<h1 className="text-3xl font-extrabold tracking-tight">{displayRecipe.name}</h1>
|
||||||
{recipe.prepTimeMinutes != null && (
|
{displayRecipe.prepTimeMinutes != null && (
|
||||||
<p className="inline-flex items-center gap-1.5 text-sm text-slate-500 dark:text-slate-400">
|
<p className="inline-flex items-center gap-1.5 text-sm text-slate-500 dark:text-slate-400">
|
||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
{t("recipes.prepTime", { count: recipe.prepTimeMinutes })}
|
{t("recipes.prepTime", { count: displayRecipe.prepTimeMinutes })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<Macro label={t("recipes.calories")} value={recipe.calories} suffix="kcal" accent="text-orange-500" />
|
<Macro
|
||||||
<Macro label={t("recipes.protein")} value={recipe.protein} suffix="g" accent="text-brand-600" />
|
label={t("recipes.calories")}
|
||||||
<Macro label={t("recipes.fat")} value={recipe.fat} suffix="g" accent="text-amber-500" />
|
value={displayRecipe.calories}
|
||||||
<Macro label={t("recipes.carbs")} value={recipe.carbs} suffix="g" accent="text-sky-500" />
|
suffix="kcal"
|
||||||
|
accent="text-orange-500"
|
||||||
|
highlight={showingPreview}
|
||||||
|
/>
|
||||||
|
<Macro
|
||||||
|
label={t("recipes.protein")}
|
||||||
|
value={displayRecipe.protein}
|
||||||
|
suffix="g"
|
||||||
|
accent="text-brand-600"
|
||||||
|
highlight={showingPreview}
|
||||||
|
/>
|
||||||
|
<Macro
|
||||||
|
label={t("recipes.fat")}
|
||||||
|
value={displayRecipe.fat}
|
||||||
|
suffix="g"
|
||||||
|
accent="text-amber-500"
|
||||||
|
highlight={showingPreview}
|
||||||
|
/>
|
||||||
|
<Macro
|
||||||
|
label={t("recipes.carbs")}
|
||||||
|
value={displayRecipe.carbs}
|
||||||
|
suffix="g"
|
||||||
|
accent="text-sky-500"
|
||||||
|
highlight={showingPreview}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card space-y-3 p-4">
|
||||||
|
<h2 className="text-sm font-bold">{t("recipes.recalculateCaloriesTitle")}</h2>
|
||||||
|
<p className="text-sm text-slate-500">{t("recipes.recalculateCaloriesHint")}</p>
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<label className="block text-sm">
|
||||||
|
{t("recipes.targetCalories")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={50}
|
||||||
|
max={10000}
|
||||||
|
className="input mt-1 w-36"
|
||||||
|
value={targetCalories}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTargetCalories(e.target.value);
|
||||||
|
setPreview(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={pending || applyPending}
|
||||||
|
onClick={() => void runRecalculate(false)}
|
||||||
|
>
|
||||||
|
{pending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||||
|
{t("recipes.recalculate")}
|
||||||
|
</button>
|
||||||
|
{showingPreview ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={pending || applyPending}
|
||||||
|
onClick={() => void runRecalculate(true)}
|
||||||
|
>
|
||||||
|
{applyPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
|
{t("recipes.applyRecalculation")}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{showingPreview ? (
|
||||||
|
<p className="text-sm text-brand-700 dark:text-brand-300">
|
||||||
|
{t("recipes.recalculatePreview", {
|
||||||
|
previous: preview.previousCalories ?? "—",
|
||||||
|
next: preview.newCalories ?? "—",
|
||||||
|
target: preview.targetCalories,
|
||||||
|
})}
|
||||||
|
{preview.unlinkedIngredientCount > 0
|
||||||
|
? ` ${t("recipes.recalculateUnlinkedHint", { count: preview.unlinkedIngredientCount })}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="grid gap-8 lg:grid-cols-[1fr_1.4fr]">
|
<div className="grid gap-8 lg:grid-cols-[1fr_1.4fr]">
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-lg font-bold">{t("recipes.ingredients")}</h2>
|
<h2 className="text-lg font-bold">{t("recipes.ingredients")}</h2>
|
||||||
{recipe.ingredients.length === 0 ? (
|
{displayRecipe.ingredients.length === 0 ? (
|
||||||
<p className="mt-3 text-sm text-slate-500">{t("recipes.noIngredients")}</p>
|
<p className="mt-3 text-sm text-slate-500">{t("recipes.noIngredients")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-4 overflow-hidden rounded-2xl border border-slate-200 dark:border-slate-800">
|
<div className="mt-4 overflow-hidden rounded-2xl border border-slate-200 dark:border-slate-800">
|
||||||
@@ -104,14 +261,31 @@ export function RecipeDetail({ recipe }: RecipeDetailProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
|
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
|
||||||
{sortedIngredients.map((ing) => (
|
{sortedIngredients.map((ing) => {
|
||||||
|
const previous = originalAmounts.get(ing.id);
|
||||||
|
const changed =
|
||||||
|
showingPreview &&
|
||||||
|
previous != null &&
|
||||||
|
ing.amountGrams != null &&
|
||||||
|
previous !== ing.amountGrams;
|
||||||
|
return (
|
||||||
<li key={ing.id} className="grid grid-cols-[1fr_auto] items-center px-4 py-3 text-sm">
|
<li key={ing.id} className="grid grid-cols-[1fr_auto] items-center px-4 py-3 text-sm">
|
||||||
<span>{ing.name}</span>
|
<span>{ing.name}</span>
|
||||||
<span className="font-medium text-slate-500 dark:text-slate-400">
|
<span
|
||||||
|
className={`font-medium ${
|
||||||
|
changed ? "text-brand-700 dark:text-brand-300" : "text-slate-500 dark:text-slate-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{formatAmount(ing.amountGrams, ing.unit)}
|
{formatAmount(ing.amountGrams, ing.unit)}
|
||||||
|
{changed ? (
|
||||||
|
<span className="ml-2 text-xs text-slate-400 line-through">
|
||||||
|
{formatAmount(previous, ing.unit)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -119,11 +293,11 @@ export function RecipeDetail({ recipe }: RecipeDetailProps) {
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-lg font-bold">{t("recipes.preparation")}</h2>
|
<h2 className="text-lg font-bold">{t("recipes.preparation")}</h2>
|
||||||
{recipe.steps.length === 0 ? (
|
{displayRecipe.steps.length === 0 ? (
|
||||||
<p className="mt-3 text-sm text-slate-500">{t("recipes.noSteps")}</p>
|
<p className="mt-3 text-sm text-slate-500">{t("recipes.noSteps")}</p>
|
||||||
) : (
|
) : (
|
||||||
<ol className="mt-4 space-y-4">
|
<ol className="mt-4 space-y-4">
|
||||||
{recipe.steps.map((step) => (
|
{displayRecipe.steps.map((step) => (
|
||||||
<li key={step.id} className="flex gap-4">
|
<li key={step.id} className="flex gap-4">
|
||||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-brand-600 text-sm font-bold text-white">
|
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-brand-600 text-sm font-bold text-white">
|
||||||
{step.stepNumber}
|
{step.stepNumber}
|
||||||
|
|||||||
@@ -161,7 +161,16 @@
|
|||||||
"noIngredients": "No ingredients listed.",
|
"noIngredients": "No ingredients listed.",
|
||||||
"noSteps": "No steps listed.",
|
"noSteps": "No steps listed.",
|
||||||
"kcal": "{{count}} kcal",
|
"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": {
|
"manage": {
|
||||||
"title": "Manage Meals",
|
"title": "Manage Meals",
|
||||||
|
|||||||
@@ -165,7 +165,16 @@
|
|||||||
"noIngredients": "Brak składników.",
|
"noIngredients": "Brak składników.",
|
||||||
"noSteps": "Brak kroków.",
|
"noSteps": "Brak kroków.",
|
||||||
"kcal": "{{count}} kcal",
|
"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": {
|
"manage": {
|
||||||
"title": "Zarządzaj posiłkami",
|
"title": "Zarządzaj posiłkami",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -6,13 +7,16 @@ import { RecipeDetail } from "@/components/recipes/RecipeDetail";
|
|||||||
import { Skeleton } from "@/components/ui/Skeleton";
|
import { Skeleton } from "@/components/ui/Skeleton";
|
||||||
import { ErrorState } from "@/components/ui/ErrorState";
|
import { ErrorState } from "@/components/ui/ErrorState";
|
||||||
import { extractApiError } from "@/api/axiosInstance";
|
import { extractApiError } from "@/api/axiosInstance";
|
||||||
|
import type { RecipeDetail as RecipeDetailModel } from "@/types/recipe.types";
|
||||||
|
|
||||||
export function RecipeDetailPage() {
|
export function RecipeDetailPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const recipeId = Number(id);
|
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<RecipeDetailModel | null>(null);
|
||||||
|
const displayRecipe = recipe ?? fetchedRecipe ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -41,7 +45,15 @@ export function RecipeDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && !isError && recipe && <RecipeDetail recipe={recipe} />}
|
{!isLoading && !isError && displayRecipe && (
|
||||||
|
<RecipeDetail
|
||||||
|
recipe={displayRecipe}
|
||||||
|
onRecipeUpdated={(updated) => {
|
||||||
|
setRecipe(updated);
|
||||||
|
void refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,3 +37,18 @@ export interface RecipeMacroRecalcResult {
|
|||||||
recipesUpdated: number;
|
recipesUpdated: number;
|
||||||
unlinkedIngredientRows: 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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user