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; 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; const formDefaults = initialValues ?? EMPTY_DEFAULT; const { register, control, handleSubmit, reset, watch, formState: { errors }, } = useForm({ 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 (
{error && (
{error}
)}

{t("manage.basicInfo")}

{errors.name &&

{errors.name.message}

}
{estimatedMacros ? (

{t("manage.estimatedFromCatalog")}: {estimatedMacros.calories} kcal · P{" "} {estimatedMacros.protein.toFixed(1)}g · F {estimatedMacros.fat.toFixed(1)}g · C{" "} {estimatedMacros.carbs.toFixed(1)}g

) : null} {unlinkedCount > 0 ? (

{t("manage.unlinkedIngredientsWarning", { count: unlinkedCount })}

) : null}

{t("recipes.ingredients")}

{ingredientFields.map((field, index) => { const ing = watchedIngredients?.[index]; const unlinked = ing ? isUnlinkedMacroIngredient(ing) : false; return (
); })}

{t("manage.preparationSteps")}

{errors.steps?.message &&

{errors.steps.message}

}
{stepFields.map((field, index) => (
))}
); }