batchCooking/apps/web/src/pages/RecipesPage.tsx
Nicolas 978fe71a11 feat(recipes): flag les ingrédients faisables maison + suggestion de recherche
Remplace la FK morte `Ingredient.alternateRecipeId` (jamais branchée nulle
part — confirmé par exploration : zéro usage en dehors de schema.prisma)
par un flag booléen `reproducible`, plus simple : pas de liaison
recette↔ingrédient en base, juste une info "ça vaut le coup d'être fait
maison" plus un raccourci de recherche.

- Migration : drop `alternate_recipe` (colonne + FK), ajoute
  `reproducible BOOLEAN NOT NULL DEFAULT false` sur `ingredients`.
- `reference-seed-data.ts` : `IngredientSeed` gagne `reproducible?`,
  threadé dans le flatten + la réconciliation `seedReferenceData`. Premier
  lot de 27 ingrédients marqués (pains, pâtes à cuire, sauces de base,
  bouillons/fonds) — même logique que la curation Ciqual : un lot solide
  plutôt qu'exhaustif sur les 546 ingrédients.
- `IngredientView` (shared) + les deux endroits qui la construisent
  (`reference.service.ts`, `recipe.service.ts`) gagnent `reproducible`.
- `ReproducibleBadge` (nouveau) : pastille "Faisable maison" — simple
  dans `IngredientPicker` (avec son propre toggle d'affichage), lien
  cliquable dans `IngredientRow` vers `/recettes?search=<nom>` ouvert
  dans un nouvel onglet (pour ne jamais perdre le formulaire de recette
  en cours — pas de persistance de brouillon dans `RecipeFormPage`).
- `RecipesPage` lit `?search=` au montage pour permettre ce deep-link.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 19:09:39 +02:00

183 lines
7 KiB
TypeScript

import { ErrorCode, type RecipeSummaryView, type RecipeTab } 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 { RecipeTable } from "../features/recipes/RecipeTable";
import { RecipeTabs } 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<RecipeTab>("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(() => {
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>
<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} />
<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>
);
}