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.
75 lines
2.6 KiB
TypeScript
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>
|
|
);
|
|
}
|