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>
112 lines
4.6 KiB
TypeScript
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>
|
|
);
|
|
}
|