batchCooking/apps/web/src/features/planning/RecipePickerDialog.tsx
Nicolas ae80537b7c refactor(web): regroupe features/recipes/ par sous-domaine au lieu d'un dossier à plat
20 fichiers à plat -> badges/ (DietTagSelect, DietBadges, AllergenBadges,
ReproducibleBadge, FavoriteStarButton), ingredients/ (IngredientPicker,
IngredientRow, ingredient-icons), steps/ (StepListEditor, StepDescription,
highlight-tech-steps), sources/ (RecipeSourcesPanel, SourceItemTable,
RecipeImportForm, recipe-import-draft, useEnabledSources).

RecipeTable/RecipeTabs/RecipeDetailPanel et recipes.scss restent à la
racine (composants transverses aux sous-dossiers, partagés par plusieurs
d'entre eux). Chemins relatifs corrigés dans les fichiers déplacés et chez
tous leurs importeurs externes (pages/recipes/*, features/planning/
RecipePickerDialog.tsx, features/profile/DislikedIngredientsField.tsx),
doc mise à jour (specs/frontend-architecture.md, specs/batch-cooking-
modele.md).

Vérifié : tsc --noEmit, biome check, build complet, 303 tests API,
vérification live navigateur (planning, /recettes, /recettes/nouvelle).

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

538 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/badges/DietTagSelect";
import { IngredientPicker } from "../recipes/ingredients/IngredientPicker";
import { RecipeDetailPanel, type RecipeDetailState } from "../recipes/RecipeDetailPanel";
import { RecipeTable } from "../recipes/RecipeTable";
import {
isSourceTab,
parseSourceTabValue,
type RecipesPageTab,
RecipeTabs,
} from "../recipes/RecipeTabs";
import { RecipeImportForm } from "../recipes/sources/RecipeImportForm";
import {
RecipeSourcesPanel,
type SourceItemSelection,
} from "../recipes/sources/RecipeSourcesPanel";
import { tryBuildCompleteImport } from "../recipes/sources/recipe-import-draft";
import { useEnabledSources } from "../recipes/sources/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);
void 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.
void 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>
);
}