Dernière étape du plan « onglet Sources » : le sélecteur de recette du planning (`RecipePickerDialog`) gagne l'onglet « Sources », jusqu'ici volontairement exclu faute d'écran de revue à qui transmettre un item choisi (voir étape 3, #47). - Sélectionner un item déjà importé se comporte exactement comme choisir cette même recette depuis un onglet normal (résolue via `GET /recipes/:id`, direction vers l'étape « combien de portions ? » du dialogue, sans navigation). - Sélectionner un item pas encore importé bascule vers l'écran de revue existant (`ImportRecipePage`), avec le créneau du planning porté par la query string (`?planningDate=&planningWeekDay=&planningMeal=`). Un import réussi y ajoute alors automatiquement la recette fraîchement créée à ce créneau (`POST /planning/items`, avec les portions du formulaire) avant de revenir sur le planning — plutôt que d'atterrir sur la page de la recette comme le fait un import « classique ». - `RecipeSourcesPanel`/`SourceItemPreviewPanel` généralisés en conséquence : la première ne navigue plus elle-même vers la recette déjà importée (`onSelectImportedRecipe` renvoie l'id, chaque appelant décide), la seconde propage le créneau optionnel sur son lien d'import. Aucun changement backend : `POST /sources/:sourceKey/import/:externalId` et `POST /planning/items` existaient déjà et suffisent tels quels — une fois la recette importée, `GET /sources/:sourceKey/browse` la marque déjà `alreadyImported` automatiquement (logique déjà couverte par `sources.test.ts`). 282 tests API toujours au vert, aucune régression. Tests : - Cypress : nouveau scénario Gherkin bout-en-bout (`cypress/e2e/planning.feature`/`planning.ts`) — ouvrir le sélecteur depuis un créneau vide, parcourir Sources, importer un item non résolu (ingrédient à compléter compris), vérifier que la requête d'ajout au planning porte bien le bon créneau/les bonnes portions, que la recette apparaît dans la bonne case de la grille après le retour sur "/", puis que rebrowser la source la marque désormais comme déjà importée. Suite : plan « onglet Sources » terminé (étapes 1 à 4). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
343 lines
13 KiB
TypeScript
343 lines
13 KiB
TypeScript
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<RecipesPageTab>("favoris");
|
|
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" });
|
|
// 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<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(() => {
|
|
// 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 (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Dialog onClose={onClose} title={t("planning.picker.title")} className="recipe-picker-dialog">
|
|
{activeTab !== "sources" && (
|
|
<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={setActiveTab} />
|
|
|
|
{activeTab === "sources" ? (
|
|
<>
|
|
{sourceSelectError && (
|
|
<p className="recipes-page__status recipes-page__status--error">
|
|
{t("common.loadError")}
|
|
</p>
|
|
)}
|
|
<RecipeSourcesPanel
|
|
planningSlot={slot}
|
|
onSelectImportedRecipe={handleSelectImportedRecipe}
|
|
/>
|
|
</>
|
|
) : (
|
|
<>
|
|
{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 && (
|
|
<RecipeTable
|
|
recipes={listState.recipes}
|
|
selectedId={null}
|
|
onSelect={(id) => {
|
|
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));
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</Dialog>
|
|
);
|
|
}
|