* Extended functionalities
* Added different UIs
This commit is contained in:
306
meal-plan-frontend/src/components/recipes/RecipeForm.tsx
Normal file
306
meal-plan-frontend/src/components/recipes/RecipeForm.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import { useMemo } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { fetchIngredientCatalogItems } from "@/api/ingredient-catalog.api";
|
||||
import { MealCategory } from "@/types/recipe.types";
|
||||
import { useMealCategoryLabels } from "@/i18n/useMealCategoryLabel";
|
||||
import {
|
||||
countUnlinkedMacroIngredients,
|
||||
estimateMacrosFromCatalog,
|
||||
isUnlinkedMacroIngredient,
|
||||
} from "@/utils/recipe-macros.util";
|
||||
import { ingredientDisplayName } from "@/utils/ingredient-catalog.util";
|
||||
import type { CreateRecipeInput } from "@/types/recipe-management.types";
|
||||
|
||||
const MEAL_CATEGORIES = [
|
||||
MealCategory.Breakfast,
|
||||
MealCategory.SecondBreakfast,
|
||||
MealCategory.Lunch,
|
||||
MealCategory.Dinner,
|
||||
] as const;
|
||||
|
||||
const EMPTY_DEFAULT: CreateRecipeInput = {
|
||||
name: "",
|
||||
mealCategory: MealCategory.Breakfast,
|
||||
calories: null,
|
||||
protein: null,
|
||||
fat: null,
|
||||
carbs: null,
|
||||
prepTimeMinutes: null,
|
||||
ingredients: [{ name: "", amountGrams: null, unit: "", sortOrder: 0, catalogItemId: null }],
|
||||
steps: [{ stepNumber: 1, description: "" }],
|
||||
};
|
||||
|
||||
interface RecipeFormProps {
|
||||
onSubmit: (data: CreateRecipeInput) => Promise<void>;
|
||||
pending?: boolean;
|
||||
error?: string | null;
|
||||
initialValues?: CreateRecipeInput;
|
||||
submitLabel?: string;
|
||||
resetOnSubmit?: boolean;
|
||||
}
|
||||
|
||||
export function RecipeForm({
|
||||
onSubmit,
|
||||
pending,
|
||||
error,
|
||||
initialValues,
|
||||
submitLabel,
|
||||
resetOnSubmit = true,
|
||||
}: RecipeFormProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const categoryLabels = useMealCategoryLabels();
|
||||
|
||||
const catalogQuery = useQuery({
|
||||
queryKey: ["ingredient-catalog", "all"],
|
||||
queryFn: () => fetchIngredientCatalogItems(),
|
||||
});
|
||||
|
||||
const recipeSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
name: z.string().min(1, t("validation.recipeNameRequired")).max(255),
|
||||
mealCategory: z.nativeEnum(MealCategory),
|
||||
calories: z.coerce.number().int().min(0).nullable().optional(),
|
||||
protein: z.coerce.number().min(0).nullable().optional(),
|
||||
fat: z.coerce.number().min(0).nullable().optional(),
|
||||
carbs: z.coerce.number().min(0).nullable().optional(),
|
||||
prepTimeMinutes: z.coerce.number().int().min(0).nullable().optional(),
|
||||
ingredients: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().min(1, t("validation.ingredientNameRequired")),
|
||||
amountGrams: z.coerce.number().min(0).nullable().optional(),
|
||||
unit: z.string().optional(),
|
||||
sortOrder: z.coerce.number().int().min(0).default(0),
|
||||
catalogItemId: z.coerce.number().nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
steps: z
|
||||
.array(
|
||||
z.object({
|
||||
stepNumber: z.coerce.number().int().min(1, t("validation.stepNumberMin")),
|
||||
description: z.string().min(1, t("validation.stepDescriptionRequired")),
|
||||
}),
|
||||
)
|
||||
.min(1, t("validation.stepsMin")),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
type RecipeFormValues = z.infer<typeof recipeSchema>;
|
||||
|
||||
const formDefaults = initialValues ?? EMPTY_DEFAULT;
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<RecipeFormValues>({
|
||||
resolver: zodResolver(recipeSchema),
|
||||
defaultValues: formDefaults,
|
||||
});
|
||||
|
||||
const {
|
||||
fields: ingredientFields,
|
||||
append: appendIngredient,
|
||||
remove: removeIngredient,
|
||||
} = useFieldArray({ control, name: "ingredients" });
|
||||
|
||||
const {
|
||||
fields: stepFields,
|
||||
append: appendStep,
|
||||
remove: removeStep,
|
||||
} = useFieldArray({ control, name: "steps" });
|
||||
|
||||
const watchedIngredients = watch("ingredients");
|
||||
const catalogItems = catalogQuery.data ?? [];
|
||||
|
||||
const estimatedMacros = useMemo(
|
||||
() => estimateMacrosFromCatalog(watchedIngredients ?? [], catalogItems),
|
||||
[watchedIngredients, catalogItems],
|
||||
);
|
||||
|
||||
const unlinkedCount = useMemo(
|
||||
() => countUnlinkedMacroIngredients(watchedIngredients ?? []),
|
||||
[watchedIngredients],
|
||||
);
|
||||
|
||||
const submit = handleSubmit(async (values) => {
|
||||
await onSubmit({
|
||||
name: values.name,
|
||||
mealCategory: values.mealCategory,
|
||||
calories: values.calories ?? null,
|
||||
protein: values.protein ?? null,
|
||||
fat: values.fat ?? null,
|
||||
carbs: values.carbs ?? null,
|
||||
prepTimeMinutes: values.prepTimeMinutes ?? null,
|
||||
ingredients: values.ingredients.map((i, index) => ({
|
||||
name: i.name,
|
||||
amountGrams: i.amountGrams ?? null,
|
||||
unit: i.unit ?? "",
|
||||
sortOrder: i.sortOrder ?? index,
|
||||
catalogItemId: i.catalogItemId ?? null,
|
||||
})),
|
||||
steps: values.steps,
|
||||
});
|
||||
if (resetOnSubmit) reset(EMPTY_DEFAULT);
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-8" noValidate>
|
||||
{error && (
|
||||
<div role="alert" className="rounded-xl bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="card space-y-4 p-6">
|
||||
<h2 className="text-lg font-bold">{t("manage.basicInfo")}</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="mb-1.5 block text-sm font-medium">{t("manage.recipeName")}</label>
|
||||
<input className="input" {...register("name")} placeholder={t("manage.recipeNamePlaceholder")} />
|
||||
{errors.name && <p className="mt-1 text-sm text-red-600">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium">{t("common.category")}</label>
|
||||
<div className="relative">
|
||||
<select className="select" {...register("mealCategory", { valueAsNumber: true })}>
|
||||
{MEAL_CATEGORIES.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{categoryLabels[cat]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium">{t("manage.prepTimeMin")}</label>
|
||||
<input type="number" className="input" {...register("prepTimeMinutes")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium">{t("manage.caloriesKcal")}</label>
|
||||
<input type="number" className="input" {...register("calories")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium">{t("manage.proteinG")}</label>
|
||||
<input type="number" step="0.1" className="input" {...register("protein")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium">{t("manage.fatG")}</label>
|
||||
<input type="number" step="0.1" className="input" {...register("fat")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium">{t("manage.carbsG")}</label>
|
||||
<input type="number" step="0.1" className="input" {...register("carbs")} />
|
||||
</div>
|
||||
</div>
|
||||
{estimatedMacros ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("manage.estimatedFromCatalog")}: {estimatedMacros.calories} kcal · P{" "}
|
||||
{estimatedMacros.protein.toFixed(1)}g · F {estimatedMacros.fat.toFixed(1)}g · C{" "}
|
||||
{estimatedMacros.carbs.toFixed(1)}g
|
||||
</p>
|
||||
) : null}
|
||||
{unlinkedCount > 0 ? (
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
{t("manage.unlinkedIngredientsWarning", { count: unlinkedCount })}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="card space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold">{t("recipes.ingredients")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={() =>
|
||||
appendIngredient({
|
||||
name: "",
|
||||
amountGrams: null,
|
||||
unit: "",
|
||||
sortOrder: ingredientFields.length,
|
||||
catalogItemId: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> {t("common.add")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{ingredientFields.map((field, index) => {
|
||||
const ing = watchedIngredients?.[index];
|
||||
const unlinked = ing ? isUnlinkedMacroIngredient(ing) : false;
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
className={`grid gap-2 sm:grid-cols-[1fr_100px_100px_1fr_auto] rounded-lg p-1 ${
|
||||
unlinked ? "ring-1 ring-amber-400/70 bg-amber-50/50 dark:bg-amber-950/20" : ""
|
||||
}`}
|
||||
>
|
||||
<input className="input" placeholder={t("manage.ingredientName")} {...register(`ingredients.${index}.name`)} />
|
||||
<input type="number" step="0.1" className="input" placeholder={t("manage.ingredientGrams")} {...register(`ingredients.${index}.amountGrams`)} />
|
||||
<input className="input" placeholder={t("manage.ingredientUnit")} {...register(`ingredients.${index}.unit`)} />
|
||||
<select className="select" {...register(`ingredients.${index}.catalogItemId`, { setValueAs: (v) => (v === "" ? null : Number(v)) })}>
|
||||
<option value="">{t("manage.catalogLink")}</option>
|
||||
{catalogItems.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{ingredientDisplayName(item, i18n.language)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="btn-secondary !px-2.5" onClick={() => removeIngredient(index)} aria-label={t("manage.removeIngredient")}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold">{t("manage.preparationSteps")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={() => appendStep({ stepNumber: stepFields.length + 1, description: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> {t("manage.addStep")}
|
||||
</button>
|
||||
</div>
|
||||
{errors.steps?.message && <p className="text-sm text-red-600">{errors.steps.message}</p>}
|
||||
<div className="space-y-3">
|
||||
{stepFields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-2">
|
||||
<input type="number" className="input w-20 shrink-0" {...register(`steps.${index}.stepNumber`)} />
|
||||
<input className="input flex-1" placeholder={t("manage.stepPlaceholder")} {...register(`steps.${index}.description`)} />
|
||||
<button type="button" className="btn-secondary !px-2.5" onClick={() => removeStep(index)} aria-label={t("manage.removeStep")}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button type="submit" disabled={pending} className="btn-primary">
|
||||
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{pending ? t("manage.saving") : (submitLabel ?? t("manage.saveRecipe"))}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user