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("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([]); const [selectedDietIds, setSelectedDietIds] = useState([]); const [suitableForHousehold, setSuitableForHousehold] = useState(false); const [hasHousehold, setHasHousehold] = useState(false); const [isIngredientPickerOpen, setIsIngredientPickerOpen] = useState(false); const [ingredientsCatalog, setIngredientsCatalog] = useState([]); const [dietsCatalog, setDietsCatalog] = useState([]); const [listState, setListState] = useState({ 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({ 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(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(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(null); // The recipe the footer's "Confirmer" moved to this small step for — // `null` while still browsing. const [selectedRecipe, setSelectedRecipe] = useState(null); const [portions, setPortions] = useState("1"); const [isSubmitting, setIsSubmitting] = useState(false); const [submitError, setSubmitError] = useState(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; 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 ( { 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}`); } }} /> ); } if (selectedRecipe) { return (
setPortions(e.target.value)} /> {submitError &&

{submitError}

}
); } const canConfirm = previewedRecipe !== null || previewedDraft !== null; return ( } > {activeSourceKey === null && (
setSearch(e.target.value)} />
{t("planning.picker.ingredientsFilterLabel")}
{selectedIngredients.map((ingredient) => ( {t(`catalog.ingredients.${ingredient.key}`)} ))}
{isIngredientPickerOpen && ( setSelectedIngredientIds((ids) => [...ids, ingredient.id]) } /> )}
{hasHousehold && ( {t("planning.picker.suitableForHouseholdLabel")} )}
)} {activeSourceKey !== null ? ( ) : ( <> {listState.status === "loading" && (

{t("planning.picker.loading")}

)} {listState.status === "error" && (

{t("common.loadError")}

)} {listState.status === "loaded" && listState.recipes.length === 0 && (

{t("planning.picker.empty")}

)} {listState.status === "loaded" && listState.recipes.length > 0 && (
)} )}
); }