batchCooking/apps/web/src/features/planning/RecipePickerDialog.tsx
Nicolas 991f91bc0e feat(planning): ajouter au planning déclenche l'import si besoin (étape 4/4)
Dernière étape du plan « onglet Sources » : le sélecteur de recette du
planning (`RecipePickerDialog`) gagne l'onglet « Sources », jusqu'ici
volontairement exclu faute d'écran de revue à qui transmettre un item
choisi (voir étape 3, #47).

- Sélectionner un item déjà importé se comporte exactement comme
  choisir cette même recette depuis un onglet normal (résolue via
  `GET /recipes/:id`, direction vers l'étape « combien de portions ? »
  du dialogue, sans navigation).
- Sélectionner un item pas encore importé bascule vers l'écran de
  revue existant (`ImportRecipePage`), avec le créneau du planning
  porté par la query string (`?planningDate=&planningWeekDay=&planningMeal=`).
  Un import réussi y ajoute alors automatiquement la recette
  fraîchement créée à ce créneau (`POST /planning/items`, avec les
  portions du formulaire) avant de revenir sur le planning — plutôt que
  d'atterrir sur la page de la recette comme le fait un import « classique ».
- `RecipeSourcesPanel`/`SourceItemPreviewPanel` généralisés en
  conséquence : la première ne navigue plus elle-même vers la recette
  déjà importée (`onSelectImportedRecipe` renvoie l'id, chaque appelant
  décide), la seconde propage le créneau optionnel sur son lien
  d'import.

Aucun changement backend : `POST /sources/:sourceKey/import/:externalId`
et `POST /planning/items` existaient déjà et suffisent tels quels — une
fois la recette importée, `GET /sources/:sourceKey/browse` la marque
déjà `alreadyImported` automatiquement (logique déjà couverte par
`sources.test.ts`). 282 tests API toujours au vert, aucune régression.

Tests :
- Cypress : nouveau scénario Gherkin bout-en-bout
  (`cypress/e2e/planning.feature`/`planning.ts`) — ouvrir le
  sélecteur depuis un créneau vide, parcourir Sources, importer un
  item non résolu (ingrédient à compléter compris), vérifier que la
  requête d'ajout au planning porte bien le bon créneau/les bonnes
  portions, que la recette apparaît dans la bonne case de la grille
  après le retour sur "/", puis que rebrowser la source la marque
  désormais comme déjà importée.

Suite : plan « onglet Sources » terminé (étapes 1 à 4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 17:42:43 +02:00

343 lines
13 KiB
TypeScript

import {
type DietView,
ErrorCode,
type IngredientView,
type Meal,
type PlanningItemView,
type RecipeSummaryView,
type WeekDay,
} from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../api/client";
import { CheckboxOption } from "../../components/ui/Checkbox";
import { Dialog } from "../../components/ui/Dialog";
import { errorMessageService } from "../../services/error-message.service";
import { DietTagSelect } from "../recipes/DietTagSelect";
import { IngredientPicker } from "../recipes/IngredientPicker";
import { RecipeSourcesPanel } from "../recipes/RecipeSourcesPanel";
import { RecipeTable } from "../recipes/RecipeTable";
import { RecipeTabs, type RecipesPageTab } from "../recipes/RecipeTabs";
import "./recipe-picker-dialog.scss";
/** Debounce for the search field — same value as `RecipesPage`'s. */
const SEARCH_DEBOUNCE_MS = 300;
/** Load state for the filtered catalog list, same discriminated-union shape as `RecipesPage`'s `RecipeListState`. */
type ListState =
| { status: "loading" }
| { status: "loaded"; recipes: RecipeSummaryView[] }
| { status: "error" };
/**
* The (day, meal) slot a `RecipePickerDialog` is adding a recipe to —
* `date` is that day's `YYYY-MM-DD` (the specific date within the
* displayed week, not just its weekday), needed by `POST /planning/items`
* to resolve which week's `Planning` row to attach to.
*/
export interface PlanningSlot {
date: string;
weekDay: WeekDay;
meal: Meal;
}
/**
* Recipe-selection dialog opened from a planning grid cell's "+" button
* (see `PlanningPage.tsx`'s `MealCell`) — the same catalog browsing
* experience as `/recettes` (`RecipeTabs` + `RecipeTable`, reused as-is),
* with three extra filters layered on top of the plain name search
* (ingredients / regime / "convient à tout le foyer" toggle, all wired to
* `GET /recipes`'s corresponding query params) since browsing here is
* about finding something to cook, not just looking something up. The
* "Sources" tab is included too (unlike an earlier version of this dialog
* — see `ImportRecipePage`'s `planningSlot`, the review/import flow that
* made including it here worthwhile): picking an already-imported item
* behaves exactly like picking a regular recipe, and picking one that
* isn't imported yet hands off to that review screen, which adds the
* freshly-created recipe straight to this slot once it's saved.
*
* Mounted only while open (see `PlanningPage`, same conditional-mount
* convention as its own `CalendarPopover`) — every piece of local state
* below resets for free the next time it's reopened, no manual reset
* needed.
*
* Selecting a row doesn't navigate anywhere (unlike `RecipesPage`'s own
* use of `RecipeTable`) — it switches this same dialog to a small
* "how many portions?" confirmation step, then calls `POST
* /planning/items` on submit. The one exception is picking a not-yet-
* imported source item, which does navigate away entirely (to
* `/recettes/importer/...`) — that flow has its own portions field
* already, on the review screen itself.
*/
export function RecipePickerDialog({
slot,
onClose,
onAdded,
}: {
slot: PlanningSlot;
onClose: () => void;
onAdded: (item: PlanningItemView) => void;
}) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [selectedIngredientIds, setSelectedIngredientIds] = useState<number[]>([]);
const [selectedDietIds, setSelectedDietIds] = useState<number[]>([]);
const [suitableForHousehold, setSuitableForHousehold] = useState(false);
const [hasHousehold, setHasHousehold] = useState(false);
const [isIngredientPickerOpen, setIsIngredientPickerOpen] = useState(false);
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
const [listState, setListState] = useState<ListState>({ status: "loading" });
// Set when picking an already-imported source item fails to resolve to a
// real recipe (see `handleSelectImportedRecipe`) — a rare race (the
// recipe was deleted between the browse fetch and the click), surfaced
// the same way any other catalog load error is on this dialog.
const [sourceSelectError, setSourceSelectError] = useState(false);
// The recipe picked in step 1 — `null` while still browsing, set once a
// row is clicked to switch this dialog into its confirmation step.
const [selectedRecipe, setSelectedRecipe] = useState<RecipeSummaryView | null>(null);
const [portions, setPortions] = useState("1");
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
useEffect(() => {
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timeout);
}, [search]);
// Reference lists + "does the viewer have a household" — loaded once,
// they don't change while the dialog is open.
useEffect(() => {
apiClient
.getIngredients()
.then(setIngredientsCatalog)
.catch(() => setIngredientsCatalog([]));
apiClient
.getDiets()
.then(setDietsCatalog)
.catch(() => setDietsCatalog([]));
apiClient
.getCurrentHouse()
.then((house) => setHasHousehold(house !== null))
.catch(() => setHasHousehold(false));
}, []);
useEffect(() => {
// The "sources" tab doesn't query the recipe table at all — same guard
// as `RecipesPage`'s own identical effect.
if (activeTab === "sources") return;
let cancelled = false;
setListState({ status: "loading" });
apiClient
.listRecipes(activeTab, {
search: debouncedSearch.trim() || undefined,
suitableForHousehold: suitableForHousehold || undefined,
ingredientIds: selectedIngredientIds,
dietIds: selectedDietIds,
})
.then((recipes) => {
if (!cancelled) setListState({ status: "loaded", recipes });
})
.catch(() => {
if (!cancelled) setListState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [activeTab, debouncedSearch, selectedIngredientIds, selectedDietIds, suitableForHousehold]);
const selectedIngredients = ingredientsCatalog.filter((ingredient) =>
selectedIngredientIds.includes(ingredient.id),
);
/** Picking an already-imported source item (`RecipeSourcesPanel`'s "sources" tab) — resolved to its full recipe, then treated exactly like picking that same recipe from one of the regular tabs, moving straight to the confirm-portions step below. */
function handleSelectImportedRecipe(recipeId: number) {
setSourceSelectError(false);
apiClient
.getRecipe(recipeId)
.then((recipe) => {
setSelectedRecipe(recipe);
setPortions(String(recipe.portions));
})
.catch(() => setSourceSelectError(true));
}
async function handleConfirm() {
if (!selectedRecipe) return;
const parsedPortions = Number(portions);
if (!Number.isInteger(parsedPortions) || parsedPortions < 1) return;
setIsSubmitting(true);
setSubmitError(null);
try {
const item = await apiClient.addPlanningItem({
date: slot.date,
weekDay: slot.weekDay,
meal: slot.meal,
recipeId: selectedRecipe.id,
portions: parsedPortions,
});
onAdded(item);
onClose();
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setSubmitError(errorMessageService.getLabel(code));
setIsSubmitting(false);
}
}
if (selectedRecipe) {
return (
<Dialog
onClose={onClose}
title={t("planning.picker.confirmTitle", { recipe: selectedRecipe.name })}
>
<div className="recipe-picker-confirm">
<label htmlFor="planning-picker-portions">{t("planning.picker.portionsLabel")}</label>
<input
id="planning-picker-portions"
type="number"
min="1"
step="1"
value={portions}
onChange={(e) => setPortions(e.target.value)}
/>
{submitError && <p className="field-error">{submitError}</p>}
<div className="recipe-picker-confirm__actions">
<button type="button" onClick={() => setSelectedRecipe(null)} disabled={isSubmitting}>
{t("planning.picker.backButton")}
</button>
<button
type="button"
className="recipe-picker-confirm__confirm"
onClick={handleConfirm}
disabled={isSubmitting}
>
{isSubmitting ? t("planning.picker.adding") : t("planning.picker.confirmButton")}
</button>
</div>
</div>
</Dialog>
);
}
return (
<Dialog onClose={onClose} title={t("planning.picker.title")} className="recipe-picker-dialog">
{activeTab !== "sources" && (
<div className="recipe-picker__filters">
<input
type="search"
className="recipe-picker__search"
placeholder={t("planning.picker.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="recipe-picker__ingredient-filter">
<span className="recipe-picker__filter-label">
{t("planning.picker.ingredientsFilterLabel")}
</span>
<div className="recipe-picker__chips">
{selectedIngredients.map((ingredient) => (
<span key={ingredient.id} className="filter-chip">
{t(`catalog.ingredients.${ingredient.key}`)}
<button
type="button"
onClick={() =>
setSelectedIngredientIds((ids) => ids.filter((id) => id !== ingredient.id))
}
>
</button>
</span>
))}
<button
type="button"
className="recipe-picker__toggle-ingredient-picker"
onClick={() => setIsIngredientPickerOpen((open) => !open)}
aria-expanded={isIngredientPickerOpen}
>
+{" "}
{isIngredientPickerOpen
? t("planning.picker.hideIngredientPicker")
: t("planning.picker.addIngredientFilter")}
</button>
</div>
{isIngredientPickerOpen && (
<IngredientPicker
ingredients={ingredientsCatalog}
excludeIds={selectedIngredientIds}
onSelect={(ingredient) =>
setSelectedIngredientIds((ids) => [...ids, ingredient.id])
}
/>
)}
</div>
<DietTagSelect
diets={dietsCatalog}
value={selectedDietIds}
onChange={setSelectedDietIds}
/>
{hasHousehold && (
<CheckboxOption checked={suitableForHousehold} onChange={setSuitableForHousehold}>
{t("planning.picker.suitableForHouseholdLabel")}
</CheckboxOption>
)}
</div>
)}
<RecipeTabs active={activeTab} onChange={setActiveTab} />
{activeTab === "sources" ? (
<>
{sourceSelectError && (
<p className="recipes-page__status recipes-page__status--error">
{t("common.loadError")}
</p>
)}
<RecipeSourcesPanel
planningSlot={slot}
onSelectImportedRecipe={handleSelectImportedRecipe}
/>
</>
) : (
<>
{listState.status === "loading" && (
<p className="recipes-page__status">{t("planning.picker.loading")}</p>
)}
{listState.status === "error" && (
<p className="recipes-page__status recipes-page__status--error">
{t("common.loadError")}
</p>
)}
{listState.status === "loaded" && listState.recipes.length === 0 && (
<p className="recipes-page__status">{t("planning.picker.empty")}</p>
)}
{listState.status === "loaded" && listState.recipes.length > 0 && (
<RecipeTable
recipes={listState.recipes}
selectedId={null}
onSelect={(id) => {
const recipe = listState.recipes.find((r) => r.id === id) ?? null;
setSelectedRecipe(recipe);
// Pre-fill from the recipe's own written yield rather than
// always starting at 1 — still freely editable below, this
// is just a better starting point (see `Recipe.portions`).
if (recipe) setPortions(String(recipe.portions));
}}
/>
)}
</>
)}
</Dialog>
);
}