batchCooking/apps/web/src/features/recipes/RecipeSourcesPanel.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

216 lines
8.3 KiB
TypeScript

import type { BrowsableSourceItemView, Meal, SourceView, WeekDay } from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { apiClient } from "../../api/client";
import { SourceItemPreviewPanel, type SourceItemPreviewState } from "./SourceItemPreviewPanel";
import { SourceItemTable } from "./SourceItemTable";
import "./recipes.scss";
/** Debounce for the search field — same idea/value as `RecipesPage`'s own search. */
const SEARCH_DEBOUNCE_MS = 300;
type EnabledSourcesState =
| { status: "loading" }
| { status: "loaded"; sources: SourceView[] }
| { status: "error" };
type BrowseState =
| { status: "loading" }
| { status: "loaded"; items: BrowsableSourceItemView[]; nextCursor: string | null }
| { status: "error" };
/**
* "Sources" tab content of the recipe catalog (`RecipesPage`) — a
* self-contained master-detail pair of its own (source selector + browsable
* list on the left, `SourceItemPreviewPanel` on the right), independent of
* `RecipeTable`/`RecipeDetailPanel`: it browses a source's *live* catalog
* (`GET /sources/:sourceKey/browse`), not the saved `Recipe` table, so it
* doesn't share their `RecipeTab`-based fetching at all.
*
* Selecting an already-imported item navigates straight to its real
* recipe (`/recettes/:id`, leaving this tab) — selecting one that isn't
* imported yet shows a read-only preview here instead. Turning that
* preview into an actual saved recipe (reviewing/fixing unresolved
* ingredients first) is a later stage of the same plan, not built here.
*
* `onSelectImportedRecipe` hands back the id instead of this panel
* navigating anywhere itself — what "viewing" an already-imported item
* means depends on the caller: `RecipesPage` switches its own active tab
* away from `"sources"` (its `RecipeDetailPanel`/`RecipeTable` only render
* outside that tab, so without switching first the URL would change but
* this panel would keep rendering over it) and navigates to the recipe's
* detail page, while `RecipePickerDialog` instead treats it exactly like
* picking that recipe from one of the regular tabs — moving to its own
* confirm-portions step, no navigation at all.
*/
export function RecipeSourcesPanel({
onSelectImportedRecipe,
planningSlot,
}: {
onSelectImportedRecipe: (recipeId: number) => void;
/** Forwarded as-is to `SourceItemPreviewPanel` — see its own doc comment. Only ever set by `RecipePickerDialog`. */
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
}) {
const { t } = useTranslation();
const [enabledSources, setEnabledSources] = useState<EnabledSourcesState>({ status: "loading" });
const [selectedSourceKey, setSelectedSourceKey] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
const [selectedExternalId, setSelectedExternalId] = useState<string | null>(null);
const [previewState, setPreviewState] = useState<SourceItemPreviewState>({ status: "empty" });
// Loaded once — which sources exist, crossed with which the household
// has enabled (`/parametres/foyer`). Defaults the selector to the first
// enabled one, if any.
useEffect(() => {
let cancelled = false;
Promise.all([apiClient.getSources(), apiClient.getHouseSourceIds()])
.then(([sources, enabledIds]) => {
if (cancelled) return;
const enabled = sources.filter((source) => enabledIds.includes(source.id));
setEnabledSources({ status: "loaded", sources: enabled });
setSelectedSourceKey((current) => current ?? enabled[0]?.key ?? null);
})
.catch(() => {
if (!cancelled) setEnabledSources({ status: "error" });
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timeout);
}, [search]);
useEffect(() => {
if (selectedSourceKey === null) return;
let cancelled = false;
setBrowseState({ status: "loading" });
setSelectedExternalId(null);
setPreviewState({ status: "empty" });
apiClient
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined })
.then(({ items, nextCursor }) => {
if (!cancelled) setBrowseState({ status: "loaded", items, nextCursor });
})
.catch(() => {
if (!cancelled) setBrowseState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [selectedSourceKey, debouncedSearch]);
function handleLoadMore() {
if (selectedSourceKey === null || browseState.status !== "loaded" || !browseState.nextCursor) {
return;
}
const cursor = browseState.nextCursor;
apiClient
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined, cursor })
.then(({ items, nextCursor }) => {
setBrowseState((prev) =>
prev.status === "loaded"
? { status: "loaded", items: [...prev.items, ...items], nextCursor }
: prev,
);
})
.catch(() => setBrowseState({ status: "error" }));
}
function handleSelectItem(item: BrowsableSourceItemView) {
if (item.alreadyImported && item.recipeId !== null) {
onSelectImportedRecipe(item.recipeId);
return;
}
if (selectedSourceKey === null) return;
setSelectedExternalId(item.externalId);
setPreviewState({ status: "loading" });
apiClient
.previewSourceItem(selectedSourceKey, item.externalId)
.then((draft) => setPreviewState({ status: "loaded", draft }))
.catch(() => setPreviewState({ status: "error" }));
}
if (enabledSources.status === "loading") {
return <p className="recipes-page__status">{t("recipes.loading")}</p>;
}
if (enabledSources.status === "error") {
return (
<p className="recipes-page__status recipes-page__status--error">{t("common.loadError")}</p>
);
}
if (enabledSources.sources.length === 0) {
return (
<p className="recipes-page__status">
{t("recipes.sources.noneEnabled")}{" "}
<Link to="/parametres/foyer">{t("recipes.sources.noneEnabledLink")}</Link>
</p>
);
}
return (
<>
<div className="recipes-page__header recipes-page__header--sources">
{enabledSources.sources.length > 1 && (
<select
className="source-sources-select"
aria-label={t("recipes.sources.sourceLabel")}
value={selectedSourceKey ?? ""}
onChange={(e) => setSelectedSourceKey(e.target.value)}
>
{enabledSources.sources.map((source) => (
<option key={source.key} value={source.key}>
{source.name}
</option>
))}
</select>
)}
<input
type="search"
className="recipes-page__search"
placeholder={t("recipes.sources.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="recipes-page__catalog">
{browseState.status === "loading" && (
<p className="recipes-page__status">{t("recipes.sources.loading")}</p>
)}
{browseState.status === "error" && (
<p className="recipes-page__status recipes-page__status--error">
{t("recipes.sources.loadError")}
</p>
)}
{browseState.status === "loaded" && browseState.items.length === 0 && (
<p className="recipes-page__status">{t("recipes.sources.empty")}</p>
)}
{browseState.status === "loaded" && browseState.items.length > 0 && (
<div className="source-items-column">
<SourceItemTable
items={browseState.items}
selectedExternalId={selectedExternalId}
onSelect={handleSelectItem}
/>
{browseState.nextCursor && (
<button type="button" className="source-items-load-more" onClick={handleLoadMore}>
{t("recipes.sources.loadMore")}
</button>
)}
</div>
)}
<SourceItemPreviewPanel state={previewState} planningSlot={planningSlot} />
</div>
</>
);
}