batchCooking/apps/web/src/features/planning/RecipePickerDialog.tsx
Nicolas 85fd9bae7d feat(planning): le picker prévisualise avant de confirmer, formulaire de revue intégré
Trois ajustements successifs sur le dialogue de sélection de recette
(RecipePickerDialog), demandés en continu après le premier correctif
de débordement :

1. Dialogue élargi et à hauteur fixe (95vw plafonné à 85rem, 80vh) au
   lieu de dépendre du contenu, avec la répartition liste/détail
   redéfinie en fractions du dialogue lui-même (3fr/2fr) plutôt qu'en
   vw — cette dernière suivait la largeur du viewport, sans rapport
   avec la largeur désormais fixe du dialogue.

2. Le formulaire de revue d'import (ex-ImportRecipePage) est extrait
   dans un composant partagé, RecipeImportForm — toujours monté en
   page autonome (route directe/rechargement), mais désormais aussi
   intégré comme une étape du dialogue lui-même quand un item de
   source a besoin d'une résolution manuelle, au lieu de naviguer et
   perdre le contexte du picker (recherche, filtres, créneau).

3. Cliquer sur une recette dans le dialogue ne fait plus que la
   sélectionner/prévisualiser (RecipeDetailPanel, comme /recettes) —
   plus de saut automatique vers l'étape suivante. Un nouveau pied de
   dialogue (Dialog.tsx gagne une prop ) porte Confirmer/
   Fermer : Confirmer agit sur la sélection en cours (recette réelle
   → étape portions existante ; item de source pas encore importé →
   import transparent ou formulaire intégré, point 2). Les onglets
   réguliers gagnent leur propre paire maître-détail (RecipeTable +
   RecipeDetailPanel, showActions=false) sur ce même modèle ; les
   onglets source prévisualisent désormais aussi les items déjà
   importés en interne (RecipeSourcesPanel), plus de saut direct.

Cypress (planning.feature/planning.ts) mis à jour en conséquence :
sélectionner puis confirmer sont deux étapes distinctes, le clic sur
la ligne ne déclenche plus rien tout seul.

Bug pré-existant trouvé en testant en direct (sans rapport avec ce qui
précède) : l'import d'une recette source plante avec une contrainte
d'unicité Prisma dès que deux lignes d'ingrédient se résolvent au même
ingrédient catalogue — signalé séparément (tâche en arrière-plan), pas
corrigé ici.

Vérifié en direct (navigateur, comptes de test) : sélection sans saut
d'écran, pied de dialogue activé/désactivé correctement, Confirmer sur
un item de source non résolu bascule vers le formulaire intégré,
Fermer ferme bien le dialogue.

pnpm exec tsc -b --force (web) — propre.
pnpm exec biome check — propre.
pnpm --filter web build — propre.

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

535 lines
20 KiB
TypeScript

import {
type DietView,
ErrorCode,
type IngredientView,
type Meal,
type PlanningItemView,
type RecipeSummaryView,
type RecipeView,
type WeekDay,
} from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
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 { RecipeDetailPanel, type RecipeDetailState } from "../recipes/RecipeDetailPanel";
import { RecipeImportForm } from "../recipes/RecipeImportForm";
import { RecipeSourcesPanel, type SourceItemSelection } from "../recipes/RecipeSourcesPanel";
import { RecipeTable } from "../recipes/RecipeTable";
import {
RecipeTabs,
type RecipesPageTab,
isSourceTab,
parseSourceTabValue,
} from "../recipes/RecipeTabs";
import { tryBuildCompleteImport } from "../recipes/recipe-import-draft";
import { useEnabledSources } from "../recipes/useEnabledSources";
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. Each
* household-enabled source's own tab is included too.
*
* 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.
*
* Clicking a row only ever *selects* it — same "preview before you commit"
* shape for every kind of row: a regular tab's own master-detail pair
* (`RecipeTable` + a `RecipeDetailPanel` fetched here, mirroring
* `RecipesPage`'s own layout) fetches and previews a real recipe; a
* source tab's `RecipeSourcesPanel` already previews either kind of row it
* has (already-imported or not) inline, on its own. Nothing about a click
* commits to anything by itself — the pinned footer's "Confirmer" button
* (`handleFooterConfirm`) is what acts on whichever preview is currently
* pending (`previewedRecipe`/`previewedDraft`, mutually exclusive):
* - A real recipe (regular tab, or an already-imported source item) moves
* to the small "how many portions?" step (`selectedRecipe`), same as
* before this dialog grew a footer.
* - A not-yet-imported source item is what actually imports one — nowhere
* else in the app does (see `confirmDraftSelection`) — since a source
* item only ever becomes a real, saved `Recipe` as a side effect of
* someone adding it to their planning. When the draft has everything a
* real recipe needs, it's imported and added to this slot transparently
* — no extra screen. Only when something's actually missing (an
* ingredient the automatic matcher couldn't resolve, say) does this
* switch to a third step instead, embedding the full review form
* (`RecipeImportForm`) right in this same dialog rather than navigating
* away to `ImportRecipePage` and losing the picker's own context (search
* term, filters, which slot this even was).
*/
export function RecipePickerDialog({
slot,
onClose,
onAdded,
}: {
slot: PlanningSlot;
onClose: () => void;
onAdded: (item: PlanningItemView) => void;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
const activeSourceKey = parseSourceTabValue(activeTab);
/** Switching tabs drops whatever was previewed/pending on the one just left — a stale "Confirmer" target from a different tab would be confusing at best. */
function handleTabChange(tab: RecipesPageTab) {
setActiveTab(tab);
setPreviewedRecipe(null);
setPreviewedDraft(null);
setRegularPreviewState({ status: "empty" });
}
const enabledSources = useEnabledSources();
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" });
// The regular tabs' own master-detail pair — `RecipeTable` on the left,
// this on the right, fetched on row click (mirrors `RecipesPage`'s
// identical layout). Source tabs don't use this at all: `RecipeSourcesPanel`
// previews its own rows internally.
const [regularPreviewState, setRegularPreviewState] = useState<RecipeDetailState>({
status: "empty",
});
// Which real recipe is the pending selection — from either the regular
// tabs' own preview above, or a source tab's already-imported row
// (`RecipeSourcesPanel`'s `onSelectImportedRecipe`, which already
// previewed it internally). Mutually exclusive with `previewedDraft`
// below; the footer's "Confirmer" (`handleFooterConfirm`) acts on
// whichever one is set.
const [previewedRecipe, setPreviewedRecipe] = useState<RecipeView | null>(null);
// Which not-yet-imported source item is the pending selection —
// `RecipeSourcesPanel`'s `onDraftSelected`, fired the moment such a row
// is clicked (it previews itself internally; this is just "which one").
const [previewedDraft, setPreviewedDraft] = useState<SourceItemSelection | null>(null);
// True while `confirmDraftSelection` below is resolving the footer's
// "Confirmer" for a pending draft (fetch it, maybe import it, maybe add
// it to the slot) — disables the footer for that brief window rather
// than allowing a second click mid-flight.
const [isConfirmingDraft, setIsConfirmingDraft] = useState(false);
// Set by `confirmDraftSelection`'s fallback when the pending draft needs
// a person's input before it can be imported — switches this whole
// dialog to its third step (see the top-level `if` below), embedding
// `RecipeImportForm` instead of showing it inline here.
const [reviewDraftItem, setReviewDraftItem] = useState<SourceItemSelection | null>(null);
// The recipe the footer's "Confirmer" moved to this small step for —
// `null` while still browsing.
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(() => {
// A source's own tab doesn't query the recipe table at all — same
// guard as `RecipesPage`'s own identical effect (the type-guard, not
// just `activeSourceKey !== null`, is what narrows `activeTab` to
// `RecipeTab` below).
if (isSourceTab(activeTab)) 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),
);
/** A regular tab's own row click — fetches the full recipe and previews it in this dialog's own master-detail pair, exactly like `RecipesPage` does. */
function handleSelectRegularRecipe(id: number) {
setPreviewedDraft(null);
setRegularPreviewState({ status: "loading" });
apiClient
.getRecipe(id)
.then((recipe) => {
setRegularPreviewState({ status: "loaded", recipe });
setPreviewedRecipe(recipe);
})
.catch(() => setRegularPreviewState({ status: "error" }));
}
/** A source tab's already-imported row — `RecipeSourcesPanel` already fetched and is previewing it itself; this just records it as the pending selection. */
function handleSelectImportedRecipe(recipe: RecipeView) {
setPreviewedDraft(null);
setPreviewedRecipe(recipe);
}
/** A source tab's not-yet-imported row — `RecipeSourcesPanel` previews it itself; this just records it as the pending selection. */
function handleDraftSelected(selection: SourceItemSelection) {
setPreviewedRecipe(null);
setPreviewedDraft(selection);
}
/**
* The footer's "Confirmer" — acts on whichever preview is currently
* pending. A real recipe moves to the small portions step below; a
* not-yet-imported draft runs {@link confirmDraftSelection}.
*/
function handleFooterConfirm() {
if (previewedRecipe) {
setSelectedRecipe(previewedRecipe);
setPortions(String(previewedRecipe.portions));
return;
}
if (previewedDraft) {
void confirmDraftSelection(previewedDraft);
}
}
/**
* Confirming a not-yet-imported source item — the one action in the
* whole app that actually imports one (see this component's own doc
* comment). Fetches its full draft, and when {@link tryBuildCompleteImport}
* finds nothing missing, imports it and adds it to `slot` transparently:
* no extra screen, same end result as picking any other recipe. Anything
* short of that — an unresolved ingredient, a network hiccup on any of
* these three calls — switches to the embedded review-form step instead
* (`setReviewDraftItem`), since only a person can supply what's actually
* missing.
*/
async function confirmDraftSelection(selection: SourceItemSelection) {
const { sourceKey, externalId } = selection;
setIsConfirmingDraft(true);
function needsReview() {
setIsConfirmingDraft(false);
setReviewDraftItem({ sourceKey, externalId });
}
let payload: ReturnType<typeof tryBuildCompleteImport>;
try {
payload = tryBuildCompleteImport(await apiClient.previewSourceItem(sourceKey, externalId));
} catch {
needsReview();
return;
}
if (!payload) {
needsReview();
return;
}
let saved: RecipeView;
try {
saved = await apiClient.importSourceItem(sourceKey, externalId, payload);
} catch {
needsReview();
return;
}
try {
const planningItem = await apiClient.addPlanningItem({
date: slot.date,
weekDay: slot.weekDay,
meal: slot.meal,
recipeId: saved.id,
portions: payload.portions,
});
onAdded(planningItem);
onClose();
} catch {
// The recipe itself is already saved at this point — only adding it
// to this slot failed. Land on its own page rather than retrying the
// whole import (same fallback `RecipeImportForm`'s own submit takes
// for the identical failure — see this dialog's `onImported` handler
// below).
setIsConfirmingDraft(false);
navigate(`/recettes/${saved.id}`);
}
}
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 (reviewDraftItem) {
return (
<Dialog
onClose={onClose}
title={t("recipes.sources.import.title")}
className="recipe-picker-dialog"
>
<RecipeImportForm
sourceKey={reviewDraftItem.sourceKey}
externalId={reviewDraftItem.externalId}
planningSlot={slot}
onImported={({ recipe, planningItem }) => {
if (planningItem) {
onAdded(planningItem);
onClose();
} else {
// The recipe itself is saved at this point — only adding it
// to this slot failed. Same fallback as the transparent-
// import path above: land on its own page instead of
// retrying.
navigate(`/recettes/${recipe.id}`);
}
}}
/>
</Dialog>
);
}
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>
);
}
const canConfirm = previewedRecipe !== null || previewedDraft !== null;
return (
<Dialog
onClose={onClose}
title={t("planning.picker.title")}
className="recipe-picker-dialog"
footer={
<>
<button type="button" onClick={onClose}>
{t("planning.picker.footerClose")}
</button>
<button
type="button"
className="recipe-picker-confirm__confirm"
onClick={handleFooterConfirm}
disabled={!canConfirm || isConfirmingDraft}
>
{isConfirmingDraft ? t("planning.picker.adding") : t("planning.picker.footerConfirm")}
</button>
</>
}
>
{activeSourceKey === null && (
<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={handleTabChange}
sources={enabledSources.status === "loaded" ? enabledSources.sources : []}
/>
{activeSourceKey !== null ? (
<RecipeSourcesPanel
key={activeSourceKey}
sourceKey={activeSourceKey}
onSelectImportedRecipe={handleSelectImportedRecipe}
onDraftSelected={handleDraftSelected}
/>
) : (
<>
{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 && (
<div className="recipes-page__catalog">
<RecipeTable
recipes={listState.recipes}
selectedId={previewedRecipe?.id ?? null}
onSelect={handleSelectRegularRecipe}
/>
<RecipeDetailPanel state={regularPreviewState} showActions={false} />
</div>
)}
</>
)}
</Dialog>
);
}