batchCooking/apps/web/src/features/recipes/IngredientRow.tsx
Nicolas de500e1a8a feat(recipes): catalogue de référence pour les unités d'ingrédients
Remplace l'unité texte libre de RecipeIngredient (max 20 caractères,
"g"/"grammes"/"G"... jamais fiable à additionner) par une référence
vers un nouveau catalogue Unit (id/key/type/toBaseFactor), même
traitement que Diet/Allergy/Ingredient : GET /reference/units, seedé
par reference-seed-data.ts (14 unités : gram/kilogram/milliliter/
centiliter/liter/tablespoon/teaspoon/piece/pinch/slice/clove/bunch/
sachet/sprig), sélectionnable uniquement via un <select> dans le
formulaire recette (plus de saisie libre).

`toBaseFactor` (combien d'unités de base — gramme pour MASS,
millilitre pour VOLUME — vaut une unité) pose les bases d'une future
fonctionnalité de conversion (ex. liste de courses additionnant
"500g" + "0.5kg") sans construire cette fonctionnalité elle-même —
les unités COUNT restent à toBaseFactor=1, non convertibles entre
elles (une "pincée" n'est pas une fraction fixe d'une "gousse").

Migration : recipe_ingredient.unit → unit_id (FK), breaking change
sans backfill assumé (pas de recette réelle en prod actuellement,
voir commentaire de migration) — mêmes garde-fous service-side que
ingredientId (404 UNIT_NOT_FOUND) et mêmes tests de couverture.
2026-08-19 22:34:57 +02:00

75 lines
2.6 KiB
TypeScript

import type { IngredientView, UnitView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import { AllergenBadges } from "./AllergenBadges";
import { DietBadges } from "./DietBadges";
import { ReproducibleBadge } from "./ReproducibleBadge";
import { IngredientTypeIcon } from "./ingredient-icons";
import "./recipes.scss";
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity is kept as a raw string while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input; `unit` picks from `unitsCatalog` (a closed reference list, see `Unit` in schema.prisma) rather than free text. */
export function IngredientRow({
ingredient,
quantity,
unitId,
unitsCatalog,
onQuantityChange,
onUnitChange,
onRemove,
}: {
ingredient: IngredientView;
quantity: string;
unitId: number | null;
unitsCatalog: UnitView[];
onQuantityChange: (quantity: string) => void;
onUnitChange: (unitId: number) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
return (
<li className="ingredient-row">
<span className="ingredient-row__icon" aria-hidden="true">
<IngredientTypeIcon icon={ingredient.icon} />
</span>
<span className="ingredient-row__name">{t(`catalog.ingredients.${ingredient.key}`)}</span>
<input
type="number"
min="0"
step="any"
className="ingredient-row__quantity"
value={quantity}
onChange={(e) => onQuantityChange(e.target.value)}
aria-label={t("recipes.form.quantityLabel")}
/>
<select
className="ingredient-row__unit"
value={unitId ?? ""}
onChange={(e) => onUnitChange(Number(e.target.value))}
aria-label={t("recipes.form.unitLabel")}
>
<option value="" disabled>
{t("recipes.form.unitPlaceholder")}
</option>
{unitsCatalog.map((unit) => (
<option key={unit.id} value={unit.id}>
{t(`catalog.units.${unit.key}`)}
</option>
))}
</select>
<AllergenBadges allergens={ingredient.allergens} />
<DietBadges diets={ingredient.diets} />
<ReproducibleBadge
reproducible={ingredient.reproducible}
searchLabel={t(`catalog.ingredients.${ingredient.key}`)}
/>
<button
type="button"
className="ingredient-row__remove"
onClick={onRemove}
title={t("recipes.form.removeIngredient")}
>
</button>
</li>
);
}