batchCooking/apps/web/src/features/recipes/ingredients/IngredientRow.tsx
Nicolas 16f593d5d0
Some checks failed
CI / lint (push) Successful in 1m46s
CI / build (push) Successful in 1m55s
CI / e2e (push) Successful in 8m37s
CI / intent-service-test (push) Successful in 17m3s
CI / test (push) Failing after 3h14m19s
feat(recipes): permet d'ajouter des ingredients hors-catalogue
Quand le catalogue seede ne couvre pas un ingredient, l'utilisateur pouvait
etre bloque (creation manuelle) ou perdre silencieusement la ligne (import).
Une ligne de recette accepte desormais `placeholderName` (texte libre) au
lieu de `ingredientId` : l'API cree une ligne `Ingredient` `isPlaceholder`
(cle `placeholder:<uuid>`, `displayName`, `createdById`) dans la transaction
de la recette, et emet `ingredient.placeholder_created`. Ces lignes sont
exclues de `GET /reference/ingredients` et de `ingredient-matcher`.

Front : bouton "Ajouter << ... >>" dans l'etat vide de `IngredientPicker`
(formulaire + import), badge "a completer" sur la ligne, helper
`ingredientLabel` applique partout ou un libelle d'ingredient est rendu.

Admin : `/admin/catalog/*` (+ page `apps/admin-web`) liste les placeholders
regroupes par nom normalise, "marquer traite" (`reviewedAt`) et purge des
orphelins. La promotion en vraie entree catalogue reste manuelle.

Migration `ingredient_placeholder` ecrite a la main (Postgres indisponible).
Suites Mocha DB-backed ecrites, non executees en session ; test pur
`normalizePlaceholderName` + Cypress admin-web/web verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 23:36:42 +02:00

112 lines
4.6 KiB
TypeScript

import type { IngredientView, UnitView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import { AllergenBadges } from "../badges/AllergenBadges";
import { DietBadges } from "../badges/DietBadges";
import { ReproducibleBadge } from "../badges/ReproducibleBadge";
import { IngredientTypeIcon } from "./ingredient-icons";
import { ingredientLabel } from "./ingredient-label";
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,
duplicate = false,
onQuantityChange,
onUnitChange,
onRemove,
}: {
ingredient: IngredientView;
quantity: string;
unitId: number | null;
unitsCatalog: UnitView[];
/** This ingredient is also picked by another line in the same form — `RecipeIngredient`'s primary key is `(recipeId, ingredientId)`, one row per ingredient (schema.prisma), so submitting two lines for it would fail. Never true for a manually-added line (`IngredientPicker`'s `excludeIds` already prevents picking the same ingredient twice) — only reachable via a source import, whose raw lines aren't deduplicated (e.g. "Egg Yolks" and "Eggs" both resolving to "Egg" — see issue #53's `RecipeImportForm`). */
duplicate?: boolean;
onQuantityChange: (quantity: string) => void;
onUnitChange: (unitId: number) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
// No unit picked yet — surfaced with a highlighted `<select>` + inline
// hint rather than silently keeping the form's submit button disabled
// (see issue #53: an import pre-filled from a source can land here with
// an ingredient resolved but no unit — e.g. a bare count like "4 Egg
// Yolks" — and nothing used to tell the user which line was blocking
// them, or why).
const unitMissing = unitId === null;
const needsAttention = unitMissing || duplicate;
// A free-text placeholder line (the catalog had nothing matching) — shown
// with an "à compléter" badge and none of the allergen/diet/reproducible
// badges, which carry no meaning until a maintainer promotes it to a real
// catalog entry. Its quantity/unit still work exactly like any other line.
const isPlaceholder = ingredient.isPlaceholder;
return (
<li className={needsAttention ? "ingredient-row ingredient-row--incomplete" : "ingredient-row"}>
<span className="ingredient-row__icon" aria-hidden="true">
<IngredientTypeIcon icon={ingredient.icon} />
</span>
<span className="ingredient-row__name">
{ingredientLabel(ingredient, t)}
{isPlaceholder && (
<span className="ingredient-row__placeholder-badge">
{t("recipes.form.placeholderBadge")}
</span>
)}
</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")}
aria-invalid={unitMissing}
>
<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>
{!isPlaceholder && (
<>
<AllergenBadges allergens={ingredient.allergens} />
<DietBadges diets={ingredient.diets} />
<ReproducibleBadge
reproducible={ingredient.reproducible}
searchLabel={ingredientLabel(ingredient, t)}
/>
</>
)}
<button
type="button"
className="ingredient-row__remove"
onClick={onRemove}
title={t("recipes.form.removeIngredient")}
>
✕
</button>
{unitMissing && (
<p className="field-error ingredient-row__error">{t("recipes.form.unitMissingHint")}</p>
)}
{duplicate && (
<p className="field-error ingredient-row__error">
{t("recipes.form.duplicateIngredientHint")}
</p>
)}
</li>
);
}