batchCooking/apps/web/src/pages/RecipesPage.tsx
Nicolas 991f91bc0e feat(planning): ajouter au planning déclenche l'import si besoin (étape 4/4)
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>
2026-08-20 17:42:43 +02:00

199 lines
7.7 KiB
TypeScript

import { ErrorCode, type RecipeSummaryView } from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ApiError, apiClient } from "../api/client";
import { RecipeDetailPanel, type RecipeDetailState } from "../features/recipes/RecipeDetailPanel";
import { RecipeSourcesPanel } from "../features/recipes/RecipeSourcesPanel";
import { RecipeTable } from "../features/recipes/RecipeTable";
import { RecipeTabs, type RecipesPageTab } from "../features/recipes/RecipeTabs";
import "../features/recipes/recipes.scss";
/** Debounce for the search field — avoids firing a request on every keystroke, same idea as the household name's autosave (`HouseholdSettingsPage`). */
const SEARCH_DEBOUNCE_MS = 300;
/** Load state for the catalog table (`GET /recipes?tab=...`) — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented, same pattern as `PlanningPage`'s `PlanningState`. */
type RecipeListState =
| { status: "loading" }
| { status: "loaded"; recipes: RecipeSummaryView[] }
| { status: "error" };
/**
* Recipe catalog — routed at both `/recettes` and `/recettes/:id` (the same
* component either way, see `App.tsx`): a tab bar + table on the left stay
* mounted at all times, only the right-hand detail panel changes with the
* `:id` param — a master-detail layout, not a navigation to a separate
* page (see `RecipeDetailPanel`, which replaces the earlier standalone
* `RecipeDetailPage`).
*/
export function RecipesPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const selectedId = id !== undefined ? Number(id) : null;
// `?search=` lets another page (the recipe form's "faisable maison"
// badge, see `ReproducibleBadge`) deep-link straight into a pre-filled
// search — read once on mount, not kept in sync on every keystroke
// afterwards (this page doesn't own the URL the way e.g. a shareable
// filter view would).
const [searchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
const [search, setSearch] = useState(() => searchParams.get("search") ?? "");
// Seeded from the same initial value as `search` — otherwise the first
// fetch below would fire with an empty term (the debounce effect hasn't
// run yet), then a second one 300ms later once it catches up.
const [debouncedSearch, setDebouncedSearch] = useState(() => searchParams.get("search") ?? "");
const [listState, setListState] = useState<RecipeListState>({ status: "loading" });
const [detailState, setDetailState] = useState<RecipeDetailState>({ status: "empty" });
const [dislikedIngredientIds, setDislikedIngredientIds] = useState<number[]>([]);
useEffect(() => {
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timeout);
}, [search]);
useEffect(() => {
// The "sources" tab doesn't query the recipe table at all — it browses
// a source's own live catalog instead (see `RecipeSourcesPanel`, which
// owns its own fetching entirely).
if (activeTab === "sources") return;
let cancelled = false;
setListState({ status: "loading" });
apiClient
.listRecipes(activeTab, { search: debouncedSearch.trim() || undefined })
.then((recipes) => {
if (!cancelled) setListState({ status: "loaded", recipes });
})
.catch(() => {
if (!cancelled) setListState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [activeTab, debouncedSearch]);
useEffect(() => {
if (selectedId === null) {
setDetailState({ status: "empty" });
return;
}
let cancelled = false;
setDetailState({ status: "loading" });
apiClient
.getRecipe(selectedId)
.then((recipe) => {
if (!cancelled) setDetailState({ status: "loaded", recipe });
})
.catch((err) => {
if (cancelled) return;
if (err instanceof ApiError && err.code === ErrorCode.RECIPE_NOT_FOUND) {
setDetailState({ status: "not-found" });
} else {
setDetailState({ status: "error" });
}
});
return () => {
cancelled = true;
};
}, [selectedId]);
// The viewer's personal "disliked" list only changes from the
// preferences page, never from here — loaded once, not re-fetched on
// every tab/selection change.
useEffect(() => {
apiClient
.getDislikedIngredientIds()
.then(setDislikedIngredientIds)
.catch(() => setDislikedIngredientIds([]));
}, []);
/** Keeps the table row's fav-mark and the `favoris` tab's membership in sync with a toggle made from the detail panel, without a full reload. */
function handleFavoriteToggled(recipeId: number, isFavorite: boolean) {
setDetailState((prev) =>
prev.status === "loaded" && prev.recipe.id === recipeId
? { status: "loaded", recipe: { ...prev.recipe, isFavorite } }
: prev,
);
setListState((prev) => {
if (prev.status !== "loaded") return prev;
const recipes = prev.recipes
.map((recipe) => (recipe.id === recipeId ? { ...recipe, isFavorite } : recipe))
.filter((recipe) => activeTab !== "favoris" || recipe.isFavorite);
return { status: "loaded", recipes };
});
}
/** After a delete, the removed recipe can no longer be selected, and the table must drop it too. */
function handleDeleted(recipeId: number) {
navigate("/recettes");
setListState((prev) =>
prev.status === "loaded"
? { status: "loaded", recipes: prev.recipes.filter((r) => r.id !== recipeId) }
: prev,
);
}
return (
<div className="recipes-page">
<div className="recipes-page__header">
<h1>{t("recipes.title")}</h1>
{activeTab !== "sources" && (
<input
type="search"
className="recipes-page__search"
placeholder={t("recipes.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
)}
<Link to="/recettes/nouvelle" className="recipes-page__new-button">
{t("recipes.newButton")}
</Link>
</div>
<RecipeTabs active={activeTab} onChange={setActiveTab} />
{activeTab === "sources" ? (
<RecipeSourcesPanel
onSelectImportedRecipe={(recipeId) => {
setActiveTab("favoris");
navigate(`/recettes/${recipeId}`);
}}
/>
) : (
<div className="recipes-page__catalog">
{listState.status === "loading" && (
<p className="recipes-page__status">{t("recipes.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("recipes.empty")}</p>
)}
{listState.status === "loaded" && listState.recipes.length > 0 && (
<RecipeTable
recipes={listState.recipes}
selectedId={selectedId}
onSelect={(recipeId) => navigate(`/recettes/${recipeId}`)}
/>
)}
<RecipeDetailPanel
state={detailState}
dislikedIngredientIds={dislikedIngredientIds}
onFavoriteToggled={handleFavoriteToggled}
onDeleted={handleDeleted}
/>
</div>
)}
</div>
);
}