import { type DietView, ErrorCode, type IngredientView, type Meal, type PlanningItemView, type RecipeSummaryView, type WeekDay, } from "@batch-cooking/shared"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; 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 } from "../recipes/RecipeTabs"; 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. The * "Sources" tab is included too (unlike an earlier version of this dialog * — see `ImportRecipePage`'s `planningSlot`, the review/import flow that * made including it here worthwhile): picking an already-imported item * behaves exactly like picking a regular recipe, and picking one that * isn't imported yet hands off to that review screen, which adds the * freshly-created recipe straight to this slot once it's saved. * * 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. The one exception is picking a not-yet- * imported source item, which does navigate away entirely (to * `/recettes/importer/...`) — that flow has its own portions field * already, on the review screen itself. */ export function RecipePickerDialog({ slot, onClose, onAdded, }: { slot: PlanningSlot; onClose: () => void; onAdded: (item: PlanningItemView) => void; }) { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState("favoris"); 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); // 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(() => { // The "sources" tab doesn't query the recipe table at all — same guard // as `RecipesPage`'s own identical effect. if (activeTab === "sources") 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 (`RecipeSourcesPanel`'s "sources" tab) — 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)); } 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 ( {activeTab !== "sources" && (
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")} )}
)} {activeTab === "sources" ? ( <> {sourceSelectError && (

{t("common.loadError")}

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