import { type BrowsableSourceItemView, 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 { RecipeSourcesPanel } 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: picking an * already-imported item behaves exactly like picking a regular recipe, * and picking one that isn't imported yet is what actually imports it — * nowhere else in the app does (see `handleSelectDraftItem`) — since a * source item only ever becomes a real, saved `Recipe` as a side effect of * someone adding it to their planning. * * 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. Picking a not-yet-imported source item is * handled differently still (`handleSelectDraftItem`): when the draft has * everything a real recipe needs, it's imported and added to this slot * transparently — no extra screen, same as picking anything else. Only * when something's actually missing (an ingredient the automatic matcher * couldn't resolve, say) does this navigate away entirely, to the review * screen (`/recettes/importer/...`), which has its own portions field * already. */ 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); 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" }); // 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); // True while `handleSelectDraftItem` below is resolving a not-yet- // imported item's transparent-import attempt — replaces the source tab's // whole panel with a status message for that brief window (fetch the // draft, maybe import it, maybe add it to the slot) rather than leaving // the browse list clickable mid-flight. const [isAddingDraft, setIsAddingDraft] = 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(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), ); /** Picking an already-imported source item (one of the source tabs' `RecipeSourcesPanel`) — 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)); } /** Navigates away to the full review/creation screen, pre-filled from this exact item and carrying `slot` along so a successful import there adds straight to it — the fallback `handleSelectDraftItem` below takes whenever a transparent import isn't possible. */ function goToReviewScreen(sourceKey: string, externalId: string) { navigate( `/recettes/importer/${sourceKey}/${encodeURIComponent(externalId)}` + `?planningDate=${slot.date}&planningWeekDay=${slot.weekDay}&planningMeal=${slot.meal}`, ); } /** * Picking 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 — falls back to the full review screen * (`goToReviewScreen`) instead, since only a person can supply what's * actually missing. */ async function handleSelectDraftItem(item: BrowsableSourceItemView) { if (activeSourceKey === null) return; const sourceKey = activeSourceKey; setSourceSelectError(false); setIsAddingDraft(true); let payload: ReturnType; try { payload = tryBuildCompleteImport( await apiClient.previewSourceItem(sourceKey, item.externalId), ); } catch { goToReviewScreen(sourceKey, item.externalId); return; } if (!payload) { setIsAddingDraft(false); goToReviewScreen(sourceKey, item.externalId); return; } let saved: RecipeView; try { saved = await apiClient.importSourceItem(sourceKey, item.externalId, payload); } catch { setIsAddingDraft(false); goToReviewScreen(sourceKey, item.externalId); 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 through the review form (same fallback // `ImportRecipePage`'s own submit takes for the identical failure). 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 (selectedRecipe) { return (
setPortions(e.target.value)} /> {submitError &&

{submitError}

}
); } 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 ? ( <> {sourceSelectError && (

{t("common.loadError")}

)} {isAddingDraft ? (

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

) : ( )} ) : ( <> {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 && ( { 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)); }} /> )} )}
); }