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>
538 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|