* Added recalculation
This commit is contained in:
@@ -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<RecipeMacroRecalcRes
|
||||
const { data } = await api.post<RecipeMacroRecalcResult>("/recipes/recalculate-macros");
|
||||
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 { 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 (
|
||||
<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}`}>
|
||||
{value != null ? value : "—"}
|
||||
{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 {
|
||||
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<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(
|
||||
() =>
|
||||
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 (
|
||||
<article className="space-y-8">
|
||||
<header className="space-y-3">
|
||||
<CategoryBadge category={recipe.mealCategory} />
|
||||
<h1 className="text-3xl font-extrabold tracking-tight">{recipe.name}</h1>
|
||||
{recipe.prepTimeMinutes != null && (
|
||||
<CategoryBadge category={displayRecipe.mealCategory} />
|
||||
<h1 className="text-3xl font-extrabold tracking-tight">{displayRecipe.name}</h1>
|
||||
{displayRecipe.prepTimeMinutes != null && (
|
||||
<p className="inline-flex items-center gap-1.5 text-sm text-slate-500 dark:text-slate-400">
|
||||
<Clock className="h-4 w-4" />
|
||||
{t("recipes.prepTime", { count: recipe.prepTimeMinutes })}
|
||||
{t("recipes.prepTime", { count: displayRecipe.prepTimeMinutes })}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<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 label={t("recipes.protein")} value={recipe.protein} suffix="g" accent="text-brand-600" />
|
||||
<Macro label={t("recipes.fat")} value={recipe.fat} suffix="g" accent="text-amber-500" />
|
||||
<Macro label={t("recipes.carbs")} value={recipe.carbs} suffix="g" accent="text-sky-500" />
|
||||
<Macro
|
||||
label={t("recipes.calories")}
|
||||
value={displayRecipe.calories}
|
||||
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>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-[1fr_1.4fr]">
|
||||
<section>
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
|
||||
{sortedIngredients.map((ing) => (
|
||||
<li key={ing.id} className="grid grid-cols-[1fr_auto] items-center px-4 py-3 text-sm">
|
||||
<span>{ing.name}</span>
|
||||
<span className="font-medium text-slate-500 dark:text-slate-400">
|
||||
{formatAmount(ing.amountGrams, ing.unit)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{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">
|
||||
<span>{ing.name}</span>
|
||||
<span
|
||||
className={`font-medium ${
|
||||
changed ? "text-brand-700 dark:text-brand-300" : "text-slate-500 dark:text-slate-400"
|
||||
}`}
|
||||
>
|
||||
{formatAmount(ing.amountGrams, ing.unit)}
|
||||
{changed ? (
|
||||
<span className="ml-2 text-xs text-slate-400 line-through">
|
||||
{formatAmount(previous, ing.unit)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
@@ -119,11 +293,11 @@ export function RecipeDetail({ recipe }: RecipeDetailProps) {
|
||||
|
||||
<section>
|
||||
<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>
|
||||
) : (
|
||||
<ol className="mt-4 space-y-4">
|
||||
{recipe.steps.map((step) => (
|
||||
{displayRecipe.steps.map((step) => (
|
||||
<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">
|
||||
{step.stepNumber}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<RecipeDetailModel | null>(null);
|
||||
const displayRecipe = recipe ?? fetchedRecipe ?? null;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user