307 lines
12 KiB
TypeScript
307 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|