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>
This commit is contained in:
parent
01eee1ae31
commit
991f91bc0e
8 changed files with 523 additions and 110 deletions
40
apps/web/cypress/e2e/planning.feature
Normal file
40
apps/web/cypress/e2e/planning.feature
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
Feature: Adding a recipe to the planning
|
||||||
|
As a signed-in user
|
||||||
|
I want to add a recipe to a planning slot even when I haven't imported it yet
|
||||||
|
So that browsing an external source and planning it is a single trip
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am signed in as "Alice" "Martin"
|
||||||
|
And my household id is 1
|
||||||
|
And today is frozen at "2026-08-17T09:00:00.000Z"
|
||||||
|
And the household request returns no household
|
||||||
|
And the recipe catalog contains nothing
|
||||||
|
And the ingredient and diet catalog is available for import
|
||||||
|
And the sources reference list has options
|
||||||
|
And the household has enabled TheMealDB
|
||||||
|
And browsing TheMealDB returns some items
|
||||||
|
And the planning request reflects whatever's been added so far
|
||||||
|
|
||||||
|
Scenario: Adds a not-yet-imported source item to a planning slot, importing it on the way
|
||||||
|
Given previewing TheMealDB item "9999" is available
|
||||||
|
And importing the previewed item will succeed and return id 99
|
||||||
|
And adding the imported recipe to the planning will succeed
|
||||||
|
When I visit "/"
|
||||||
|
And I click the add button for the first empty planning slot
|
||||||
|
And I click the button "Sources"
|
||||||
|
And I click the source item "Fish Pie"
|
||||||
|
And I click the link "Importer cette recette"
|
||||||
|
Then I should see "Cette recette sera automatiquement ajoutée à votre planning une fois importée."
|
||||||
|
|
||||||
|
When I choose an ingredient for the unresolved line "some mystery paste"
|
||||||
|
And I select the ingredient "Sel" from the picker
|
||||||
|
And I select unit "unité" for the first ingredient
|
||||||
|
And I fill in the last ingredient's quantity with "1" and unit "unité"
|
||||||
|
And I click the button "Importer"
|
||||||
|
Then the planning add request should have included recipe 99, weekDay "lundi", meal "petit-dejeuner", and portions 4
|
||||||
|
And the URL should be the home page
|
||||||
|
And the recipe "Fish Pie" should appear in the first planning slot with 4 portions
|
||||||
|
|
||||||
|
When I click the add button for the first empty planning slot
|
||||||
|
And I click the button "Sources"
|
||||||
|
Then the source item "Fish Pie" should be marked as already imported
|
||||||
231
apps/web/cypress/e2e/planning.ts
Normal file
231
apps/web/cypress/e2e/planning.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
|
||||||
|
|
||||||
|
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
|
||||||
|
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API
|
||||||
|
// behavior against a real database (see test/sources.test.ts,
|
||||||
|
// test/planning.test.ts).
|
||||||
|
//
|
||||||
|
// planning.feature's journey crosses both `RecipePickerDialog` (browsing an
|
||||||
|
// external source from a planning slot) and the import review screen
|
||||||
|
// (`ImportRecipePage`) it hands off to — same "each spec's own
|
||||||
|
// self-contained fixtures" precedent recipe-sources.ts already sets (the
|
||||||
|
// Cucumber preprocessor's step lookup isn't global across cypress/e2e/, see
|
||||||
|
// its own comment for the full reasoning), so most of what's below mirrors
|
||||||
|
// recipe-sources.ts's fixtures rather than importing them.
|
||||||
|
|
||||||
|
// Both flip once, from `false` to `true`, as the single scenario in this
|
||||||
|
// file actually performs the import and the planning-add — module-level
|
||||||
|
// `let` rather than something reset per-scenario, since there's only ever
|
||||||
|
// the one here (see household-settings.ts for the same pattern used across
|
||||||
|
// several scenarios instead).
|
||||||
|
let fishPieImported = false;
|
||||||
|
let fishPiePlanned = false;
|
||||||
|
|
||||||
|
Given("the recipe catalog contains nothing", () => {
|
||||||
|
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("the household has enabled TheMealDB", () => {
|
||||||
|
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stateful — "Fish Pie" starts out not imported, and flips the moment
|
||||||
|
// "importing the previewed item will succeed ..." below actually fires, so
|
||||||
|
// re-browsing after the import journey completes reflects it without a page
|
||||||
|
// reload (see this feature's closing assertions).
|
||||||
|
Given("browsing TheMealDB returns some items", () => {
|
||||||
|
cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => {
|
||||||
|
req.reply({
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
externalId: "52795",
|
||||||
|
title: "Chicken Handi",
|
||||||
|
picture: null,
|
||||||
|
url: "https://www.themealdb.com/meal/52795",
|
||||||
|
alreadyImported: true,
|
||||||
|
recipeId: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
externalId: "9999",
|
||||||
|
title: "Fish Pie",
|
||||||
|
picture: null,
|
||||||
|
url: "https://www.themealdb.com/meal/9999",
|
||||||
|
alreadyImported: fishPieImported,
|
||||||
|
recipeId: fishPieImported ? 99 : null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextCursor: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("previewing TheMealDB item {string} is available", (externalId: string) => {
|
||||||
|
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
sourceKey: "theMealDb",
|
||||||
|
externalId,
|
||||||
|
name: "Fish Pie",
|
||||||
|
description: null,
|
||||||
|
picture: null,
|
||||||
|
portions: 4,
|
||||||
|
sourceUrl: "https://www.themealdb.com/meal/9999",
|
||||||
|
ingredients: [
|
||||||
|
{
|
||||||
|
rawText: "1 onion",
|
||||||
|
quantity: 1,
|
||||||
|
ingredient: {
|
||||||
|
id: 1,
|
||||||
|
key: "onion",
|
||||||
|
icon: "VEGETABLE",
|
||||||
|
category: "freshProduce",
|
||||||
|
subcategory: "vegetables",
|
||||||
|
reproducible: false,
|
||||||
|
allergens: [],
|
||||||
|
diets: [],
|
||||||
|
},
|
||||||
|
unit: null,
|
||||||
|
},
|
||||||
|
{ rawText: "some mystery paste", quantity: null, ingredient: null, unit: null },
|
||||||
|
],
|
||||||
|
steps: [{ description: "Cuire à la poêle.", picture: null, techSteps: [] }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Covers every reference catalog both `RecipePickerDialog` (ingredients/
|
||||||
|
// diets, for its own filters) and `ImportRecipePage` (ingredients/diets/
|
||||||
|
// units, for the review form) fetch — same endpoints, one fixture for both.
|
||||||
|
Given("the ingredient and diet catalog is available for import", () => {
|
||||||
|
cy.intercept("GET", "**/reference/ingredients", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
key: "onion",
|
||||||
|
icon: "VEGETABLE",
|
||||||
|
category: "freshProduce",
|
||||||
|
subcategory: "vegetables",
|
||||||
|
allergens: [],
|
||||||
|
diets: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
key: "salt",
|
||||||
|
icon: "SPICE",
|
||||||
|
category: "condimentsAndSpices",
|
||||||
|
subcategory: "spices",
|
||||||
|
allergens: [],
|
||||||
|
diets: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
cy.intercept("GET", "**/reference/diets", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: [{ id: 1, key: "omnivore" }],
|
||||||
|
});
|
||||||
|
cy.intercept("GET", "**/reference/units", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: [{ id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("importing the previewed item will succeed and return id {int}", (id: number) => {
|
||||||
|
cy.intercept("POST", "**/sources/theMealDb/import/9999", (req) => {
|
||||||
|
fishPieImported = true;
|
||||||
|
req.reply({ statusCode: 201, body: { id } });
|
||||||
|
}).as("importRecipe");
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("adding the imported recipe to the planning will succeed", () => {
|
||||||
|
cy.intercept("POST", "**/planning/items", (req) => {
|
||||||
|
fishPiePlanned = true;
|
||||||
|
req.reply({
|
||||||
|
statusCode: 201,
|
||||||
|
body: {
|
||||||
|
id: 1,
|
||||||
|
weekDay: "lundi",
|
||||||
|
meal: "petit-dejeuner",
|
||||||
|
portions: 4,
|
||||||
|
recipe: { id: 99, name: "Fish Pie" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}).as("addPlanningItem");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stateful for the same reason as "browsing TheMealDB returns some items"
|
||||||
|
// above — landing back on "/" after the import journey remounts
|
||||||
|
// `PlanningPage` from scratch (a real cross-route navigation, not a
|
||||||
|
// same-component state update: see `ImportRecipePage`'s `navigate("/")`),
|
||||||
|
// so only a fresh `GET /planning?date=` that reflects the just-added item
|
||||||
|
// makes it show up there — nothing client-side survives that remount to
|
||||||
|
// patch it in locally the way `PlanningPage`'s own `patchPlanningItems`
|
||||||
|
// does for an add made without leaving the page.
|
||||||
|
Given("the planning request reflects whatever's been added so far", () => {
|
||||||
|
cy.intercept("GET", /\/planning\?/, (req) => {
|
||||||
|
req.reply({
|
||||||
|
statusCode: 200,
|
||||||
|
body: fishPiePlanned
|
||||||
|
? {
|
||||||
|
id: 1,
|
||||||
|
startDate: "2026-08-17T00:00:00.000Z",
|
||||||
|
finishDate: "2026-08-23T00:00:00.000Z",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
weekDay: "lundi",
|
||||||
|
meal: "petit-dejeuner",
|
||||||
|
portions: 4,
|
||||||
|
recipe: { id: 99, name: "Fish Pie" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The very first "+" in DOM order is Lundi's Petit-déjeuner cell (`MEALS`'s
|
||||||
|
// first entry × `WEEK_DAYS`'s first entry, see `PlanningGrid`) — the exact
|
||||||
|
// slot this feature's fixtures above (weekDay "lundi", meal
|
||||||
|
// "petit-dejeuner") are written against.
|
||||||
|
When("I click the add button for the first empty planning slot", () => {
|
||||||
|
cy.get(".add-recipe-btn").first().click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I click the source item {string}", (title: string) => {
|
||||||
|
cy.contains(".recipe-table__name", title).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I choose an ingredient for the unresolved line {string}", (rawText: string) => {
|
||||||
|
cy.contains(".import-recipe__unresolved-row", rawText)
|
||||||
|
.contains("button", "Choisir un ingrédient")
|
||||||
|
.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the source item {string} should be marked as already imported", (title: string) => {
|
||||||
|
cy.contains("tr", title).find(".source-item-table__imported-badge").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"the planning add request should have included recipe {int}, weekDay {string}, meal {string}, and portions {int}",
|
||||||
|
(recipeId: number, weekDay: string, meal: string, portions: number) => {
|
||||||
|
cy.wait("@addPlanningItem")
|
||||||
|
.its("request.body")
|
||||||
|
.should("deep.include", { recipeId, weekDay, meal, portions });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"the recipe {string} should appear in the first planning slot with {int} portions",
|
||||||
|
(name: string, portions: number) => {
|
||||||
|
cy.get(".planning-grid tbody tr")
|
||||||
|
.first()
|
||||||
|
.within(() => {
|
||||||
|
cy.contains(".recipe-chip", `${name} · ×${portions}`).should("be.visible");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -5,7 +5,6 @@ import {
|
||||||
type Meal,
|
type Meal,
|
||||||
type PlanningItemView,
|
type PlanningItemView,
|
||||||
type RecipeSummaryView,
|
type RecipeSummaryView,
|
||||||
type RecipeTab,
|
|
||||||
type WeekDay,
|
type WeekDay,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
@ -16,16 +15,14 @@ import { Dialog } from "../../components/ui/Dialog";
|
||||||
import { errorMessageService } from "../../services/error-message.service";
|
import { errorMessageService } from "../../services/error-message.service";
|
||||||
import { DietTagSelect } from "../recipes/DietTagSelect";
|
import { DietTagSelect } from "../recipes/DietTagSelect";
|
||||||
import { IngredientPicker } from "../recipes/IngredientPicker";
|
import { IngredientPicker } from "../recipes/IngredientPicker";
|
||||||
|
import { RecipeSourcesPanel } from "../recipes/RecipeSourcesPanel";
|
||||||
import { RecipeTable } from "../recipes/RecipeTable";
|
import { RecipeTable } from "../recipes/RecipeTable";
|
||||||
import { RecipeTabs } from "../recipes/RecipeTabs";
|
import { RecipeTabs, type RecipesPageTab } from "../recipes/RecipeTabs";
|
||||||
import "./recipe-picker-dialog.scss";
|
import "./recipe-picker-dialog.scss";
|
||||||
|
|
||||||
/** Debounce for the search field — same value as `RecipesPage`'s. */
|
/** Debounce for the search field — same value as `RecipesPage`'s. */
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
/** Restricts this dialog's `RecipeTabs` to the four real, DB-backed tabs — browsing external sources mid-dialog (`RecipeTabs`' "sources" tab) doesn't make sense here yet, with no review/import flow to hand a picked item off to. */
|
|
||||||
const REAL_RECIPE_TABS: readonly RecipeTab[] = ["favoris", "perso", "foyer", "publique"];
|
|
||||||
|
|
||||||
/** Load state for the filtered catalog list, same discriminated-union shape as `RecipesPage`'s `RecipeListState`. */
|
/** Load state for the filtered catalog list, same discriminated-union shape as `RecipesPage`'s `RecipeListState`. */
|
||||||
type ListState =
|
type ListState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
|
|
@ -51,7 +48,13 @@ export interface PlanningSlot {
|
||||||
* with three extra filters layered on top of the plain name search
|
* with three extra filters layered on top of the plain name search
|
||||||
* (ingredients / regime / "convient à tout le foyer" toggle, all wired to
|
* (ingredients / regime / "convient à tout le foyer" toggle, all wired to
|
||||||
* `GET /recipes`'s corresponding query params) since browsing here is
|
* `GET /recipes`'s corresponding query params) since browsing here is
|
||||||
* about finding something to cook, not just looking something up.
|
* 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
|
* Mounted only while open (see `PlanningPage`, same conditional-mount
|
||||||
* convention as its own `CalendarPopover`) — every piece of local state
|
* convention as its own `CalendarPopover`) — every piece of local state
|
||||||
|
|
@ -61,7 +64,10 @@ export interface PlanningSlot {
|
||||||
* Selecting a row doesn't navigate anywhere (unlike `RecipesPage`'s own
|
* Selecting a row doesn't navigate anywhere (unlike `RecipesPage`'s own
|
||||||
* use of `RecipeTable`) — it switches this same dialog to a small
|
* use of `RecipeTable`) — it switches this same dialog to a small
|
||||||
* "how many portions?" confirmation step, then calls `POST
|
* "how many portions?" confirmation step, then calls `POST
|
||||||
* /planning/items` on submit.
|
* /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({
|
export function RecipePickerDialog({
|
||||||
slot,
|
slot,
|
||||||
|
|
@ -74,7 +80,7 @@ export function RecipePickerDialog({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<RecipeTab>("favoris");
|
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
const [selectedIngredientIds, setSelectedIngredientIds] = useState<number[]>([]);
|
const [selectedIngredientIds, setSelectedIngredientIds] = useState<number[]>([]);
|
||||||
|
|
@ -86,6 +92,11 @@ export function RecipePickerDialog({
|
||||||
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
||||||
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
||||||
const [listState, setListState] = useState<ListState>({ status: "loading" });
|
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
|
// The recipe picked in step 1 — `null` while still browsing, set once a
|
||||||
// row is clicked to switch this dialog into its confirmation step.
|
// row is clicked to switch this dialog into its confirmation step.
|
||||||
|
|
@ -117,6 +128,9 @@ export function RecipePickerDialog({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
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;
|
let cancelled = false;
|
||||||
setListState({ status: "loading" });
|
setListState({ status: "loading" });
|
||||||
|
|
||||||
|
|
@ -143,6 +157,18 @@ export function RecipePickerDialog({
|
||||||
selectedIngredientIds.includes(ingredient.id),
|
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() {
|
async function handleConfirm() {
|
||||||
if (!selectedRecipe) return;
|
if (!selectedRecipe) return;
|
||||||
const parsedPortions = Number(portions);
|
const parsedPortions = Number(portions);
|
||||||
|
|
@ -204,91 +230,113 @@ export function RecipePickerDialog({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog onClose={onClose} title={t("planning.picker.title")} className="recipe-picker-dialog">
|
<Dialog onClose={onClose} title={t("planning.picker.title")} className="recipe-picker-dialog">
|
||||||
<div className="recipe-picker__filters">
|
{activeTab !== "sources" && (
|
||||||
<input
|
<div className="recipe-picker__filters">
|
||||||
type="search"
|
<input
|
||||||
className="recipe-picker__search"
|
type="search"
|
||||||
placeholder={t("planning.picker.searchPlaceholder")}
|
className="recipe-picker__search"
|
||||||
value={search}
|
placeholder={t("planning.picker.searchPlaceholder")}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
value={search}
|
||||||
/>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="recipe-picker__ingredient-filter">
|
<div className="recipe-picker__ingredient-filter">
|
||||||
<span className="recipe-picker__filter-label">
|
<span className="recipe-picker__filter-label">
|
||||||
{t("planning.picker.ingredientsFilterLabel")}
|
{t("planning.picker.ingredientsFilterLabel")}
|
||||||
</span>
|
</span>
|
||||||
<div className="recipe-picker__chips">
|
<div className="recipe-picker__chips">
|
||||||
{selectedIngredients.map((ingredient) => (
|
{selectedIngredients.map((ingredient) => (
|
||||||
<span key={ingredient.id} className="filter-chip">
|
<span key={ingredient.id} className="filter-chip">
|
||||||
{t(`catalog.ingredients.${ingredient.key}`)}
|
{t(`catalog.ingredients.${ingredient.key}`)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setSelectedIngredientIds((ids) => ids.filter((id) => id !== ingredient.id))
|
setSelectedIngredientIds((ids) => ids.filter((id) => id !== ingredient.id))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="recipe-picker__toggle-ingredient-picker"
|
className="recipe-picker__toggle-ingredient-picker"
|
||||||
onClick={() => setIsIngredientPickerOpen((open) => !open)}
|
onClick={() => setIsIngredientPickerOpen((open) => !open)}
|
||||||
aria-expanded={isIngredientPickerOpen}
|
aria-expanded={isIngredientPickerOpen}
|
||||||
>
|
>
|
||||||
+{" "}
|
+{" "}
|
||||||
{isIngredientPickerOpen
|
{isIngredientPickerOpen
|
||||||
? t("planning.picker.hideIngredientPicker")
|
? t("planning.picker.hideIngredientPicker")
|
||||||
: t("planning.picker.addIngredientFilter")}
|
: t("planning.picker.addIngredientFilter")}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
{isIngredientPickerOpen && (
|
||||||
|
<IngredientPicker
|
||||||
|
ingredients={ingredientsCatalog}
|
||||||
|
excludeIds={selectedIngredientIds}
|
||||||
|
onSelect={(ingredient) =>
|
||||||
|
setSelectedIngredientIds((ids) => [...ids, ingredient.id])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isIngredientPickerOpen && (
|
|
||||||
<IngredientPicker
|
<DietTagSelect
|
||||||
ingredients={ingredientsCatalog}
|
diets={dietsCatalog}
|
||||||
excludeIds={selectedIngredientIds}
|
value={selectedDietIds}
|
||||||
onSelect={(ingredient) => setSelectedIngredientIds((ids) => [...ids, ingredient.id])}
|
onChange={setSelectedDietIds}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{hasHousehold && (
|
||||||
|
<CheckboxOption checked={suitableForHousehold} onChange={setSuitableForHousehold}>
|
||||||
|
{t("planning.picker.suitableForHouseholdLabel")}
|
||||||
|
</CheckboxOption>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DietTagSelect diets={dietsCatalog} value={selectedDietIds} onChange={setSelectedDietIds} />
|
|
||||||
|
|
||||||
{hasHousehold && (
|
|
||||||
<CheckboxOption checked={suitableForHousehold} onChange={setSuitableForHousehold}>
|
|
||||||
{t("planning.picker.suitableForHouseholdLabel")}
|
|
||||||
</CheckboxOption>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RecipeTabs
|
|
||||||
active={activeTab}
|
|
||||||
onChange={(tab) => setActiveTab(tab as RecipeTab)}
|
|
||||||
tabs={REAL_RECIPE_TABS}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{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>
|
<RecipeTabs active={activeTab} onChange={setActiveTab} />
|
||||||
)}
|
|
||||||
{listState.status === "loaded" && listState.recipes.length === 0 && (
|
{activeTab === "sources" ? (
|
||||||
<p className="recipes-page__status">{t("planning.picker.empty")}</p>
|
<>
|
||||||
)}
|
{sourceSelectError && (
|
||||||
{listState.status === "loaded" && listState.recipes.length > 0 && (
|
<p className="recipes-page__status recipes-page__status--error">
|
||||||
<RecipeTable
|
{t("common.loadError")}
|
||||||
recipes={listState.recipes}
|
</p>
|
||||||
selectedId={null}
|
)}
|
||||||
onSelect={(id) => {
|
<RecipeSourcesPanel
|
||||||
const recipe = listState.recipes.find((r) => r.id === id) ?? null;
|
planningSlot={slot}
|
||||||
setSelectedRecipe(recipe);
|
onSelectImportedRecipe={handleSelectImportedRecipe}
|
||||||
// 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));
|
<>
|
||||||
}}
|
{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>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { BrowsableSourceItemView, SourceView } from "@batch-cooking/shared";
|
import type { BrowsableSourceItemView, Meal, SourceView, WeekDay } from "@batch-cooking/shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { apiClient } from "../../api/client";
|
import { apiClient } from "../../api/client";
|
||||||
import { SourceItemPreviewPanel, type SourceItemPreviewState } from "./SourceItemPreviewPanel";
|
import { SourceItemPreviewPanel, type SourceItemPreviewState } from "./SourceItemPreviewPanel";
|
||||||
import { SourceItemTable } from "./SourceItemTable";
|
import { SourceItemTable } from "./SourceItemTable";
|
||||||
|
|
@ -34,23 +34,25 @@ type BrowseState =
|
||||||
* preview into an actual saved recipe (reviewing/fixing unresolved
|
* preview into an actual saved recipe (reviewing/fixing unresolved
|
||||||
* ingredients first) is a later stage of the same plan, not built here.
|
* ingredients first) is a later stage of the same plan, not built here.
|
||||||
*
|
*
|
||||||
* `onViewImportedRecipe` must switch the caller's active tab away from
|
* `onSelectImportedRecipe` hands back the id instead of this panel
|
||||||
* `"sources"` before/alongside navigating — `RecipesPage` only renders
|
* navigating anywhere itself — what "viewing" an already-imported item
|
||||||
* `RecipeDetailPanel` (and fetches the real recipe list `RecipeTable`
|
* means depends on the caller: `RecipesPage` switches its own active tab
|
||||||
* needs) outside the `"sources"` tab, so without this the URL would change
|
* away from `"sources"` (its `RecipeDetailPanel`/`RecipeTable` only render
|
||||||
* but the sources panel would keep rendering over it. Which real tab it
|
* outside that tab, so without switching first the URL would change but
|
||||||
* lands on doesn't have to include the recipe (the table and the detail
|
* this panel would keep rendering over it) and navigates to the recipe's
|
||||||
* panel are only loosely coupled via the URL's `:id` everywhere else in
|
* detail page, while `RecipePickerDialog` instead treats it exactly like
|
||||||
* the app too — a deep link to a recipe outside the active tab's own list
|
* picking that recipe from one of the regular tabs — moving to its own
|
||||||
* already just shows the detail without highlighting a row).
|
* confirm-portions step, no navigation at all.
|
||||||
*/
|
*/
|
||||||
export function RecipeSourcesPanel({
|
export function RecipeSourcesPanel({
|
||||||
onViewImportedRecipe,
|
onSelectImportedRecipe,
|
||||||
|
planningSlot,
|
||||||
}: {
|
}: {
|
||||||
onViewImportedRecipe: () => void;
|
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 { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [enabledSources, setEnabledSources] = useState<EnabledSourcesState>({ status: "loading" });
|
const [enabledSources, setEnabledSources] = useState<EnabledSourcesState>({ status: "loading" });
|
||||||
const [selectedSourceKey, setSelectedSourceKey] = useState<string | null>(null);
|
const [selectedSourceKey, setSelectedSourceKey] = useState<string | null>(null);
|
||||||
|
|
@ -125,8 +127,7 @@ export function RecipeSourcesPanel({
|
||||||
|
|
||||||
function handleSelectItem(item: BrowsableSourceItemView) {
|
function handleSelectItem(item: BrowsableSourceItemView) {
|
||||||
if (item.alreadyImported && item.recipeId !== null) {
|
if (item.alreadyImported && item.recipeId !== null) {
|
||||||
onViewImportedRecipe();
|
onSelectImportedRecipe(item.recipeId);
|
||||||
navigate(`/recettes/${item.recipeId}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedSourceKey === null) return;
|
if (selectedSourceKey === null) return;
|
||||||
|
|
@ -208,7 +209,7 @@ export function RecipeSourcesPanel({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SourceItemPreviewPanel state={previewState} />
|
<SourceItemPreviewPanel state={previewState} planningSlot={planningSlot} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { RecipeImportDraftView } from "@batch-cooking/shared";
|
import type { Meal, RecipeImportDraftView, WeekDay } from "@batch-cooking/shared";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { StepDescription } from "./StepDescription";
|
import { StepDescription } from "./StepDescription";
|
||||||
|
|
@ -20,7 +20,21 @@ export type SourceItemPreviewState =
|
||||||
* Reuses `StepDescription` so a step's detected techniques are already
|
* Reuses `StepDescription` so a step's detected techniques are already
|
||||||
* highlighted here too, exactly like a saved recipe's detail.
|
* highlighted here too, exactly like a saved recipe's detail.
|
||||||
*/
|
*/
|
||||||
export function SourceItemPreviewPanel({ state }: { state: SourceItemPreviewState }) {
|
export function SourceItemPreviewPanel({
|
||||||
|
state,
|
||||||
|
planningSlot,
|
||||||
|
}: {
|
||||||
|
state: SourceItemPreviewState;
|
||||||
|
/**
|
||||||
|
* Set only when this panel is rendered from `RecipePickerDialog` (adding a
|
||||||
|
* recipe to one planning slot) rather than the standalone `/recettes`
|
||||||
|
* catalog — carried along on the "Importer cette recette" link as query
|
||||||
|
* params so `ImportRecipePage` knows to add the freshly-created recipe to
|
||||||
|
* this exact slot once the import succeeds, instead of landing on the
|
||||||
|
* recipe's own detail page. See `ImportRecipePage`'s `planningSlot`.
|
||||||
|
*/
|
||||||
|
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (state.status === "empty") {
|
if (state.status === "empty") {
|
||||||
|
|
@ -73,7 +87,12 @@ export function SourceItemPreviewPanel({ state }: { state: SourceItemPreviewStat
|
||||||
|
|
||||||
<div className="recipe-detail-panel__actions">
|
<div className="recipe-detail-panel__actions">
|
||||||
<Link
|
<Link
|
||||||
to={`/recettes/importer/${draft.sourceKey}/${encodeURIComponent(draft.externalId)}`}
|
to={{
|
||||||
|
pathname: `/recettes/importer/${draft.sourceKey}/${encodeURIComponent(draft.externalId)}`,
|
||||||
|
search: planningSlot
|
||||||
|
? `?planningDate=${planningSlot.date}&planningWeekDay=${planningSlot.weekDay}&planningMeal=${planningSlot.meal}`
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
className="recipes-page__new-button"
|
className="recipes-page__new-button"
|
||||||
>
|
>
|
||||||
{t("recipes.sources.detail.importButton")}
|
{t("recipes.sources.detail.importButton")}
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,7 @@
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Revoir l'import",
|
"title": "Revoir l'import",
|
||||||
|
"planningHint": "Cette recette sera automatiquement ajoutée à votre planning une fois importée.",
|
||||||
"loadError": "Impossible de charger cette recette pour le moment.",
|
"loadError": "Impossible de charger cette recette pour le moment.",
|
||||||
"unresolvedTitle": "Ingrédients à compléter",
|
"unresolvedTitle": "Ingrédients à compléter",
|
||||||
"unresolvedHint": "Ces lignes n'ont pas été reconnues automatiquement — choisissez le bon ingrédient, ou retirez-les.",
|
"unresolvedHint": "Ces lignes n'ont pas été reconnues automatiquement — choisissez le bon ingrédient, ou retirez-les.",
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,17 @@ import {
|
||||||
type DietView,
|
type DietView,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
type IngredientView,
|
type IngredientView,
|
||||||
|
MEALS,
|
||||||
|
type Meal,
|
||||||
type RecipeVisibility,
|
type RecipeVisibility,
|
||||||
type UnitView,
|
type UnitView,
|
||||||
|
WEEK_DAYS,
|
||||||
|
type WeekDay,
|
||||||
createRecipeSchema,
|
createRecipeSchema,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { type FormEvent, useEffect, useState } from "react";
|
import { type FormEvent, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { ApiError, apiClient } from "../api/client";
|
import { ApiError, apiClient } from "../api/client";
|
||||||
import { DietTagSelect } from "../features/recipes/DietTagSelect";
|
import { DietTagSelect } from "../features/recipes/DietTagSelect";
|
||||||
import { IngredientPicker } from "../features/recipes/IngredientPicker";
|
import { IngredientPicker } from "../features/recipes/IngredientPicker";
|
||||||
|
|
@ -39,6 +43,33 @@ interface UnresolvedIngredientLine {
|
||||||
|
|
||||||
type LoadState = "loading" | "loaded" | "error";
|
type LoadState = "loading" | "loaded" | "error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads and validates `?planningDate=&planningWeekDay=&planningMeal=` off
|
||||||
|
* this page's own URL — `null` unless all three are present and well-formed
|
||||||
|
* (a closed-set match against `WEEK_DAYS`/`MEALS`, same validation
|
||||||
|
* `addPlanningItemSchema` enforces server-side), so a hand-typed or stale
|
||||||
|
* URL just falls back to this page's normal "land on the recipe" behavior
|
||||||
|
* rather than throwing. See this component's own doc comment.
|
||||||
|
*/
|
||||||
|
function parsePlanningSlot(
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
): { date: string; weekDay: WeekDay; meal: Meal } | null {
|
||||||
|
const date = searchParams.get("planningDate");
|
||||||
|
const weekDay = searchParams.get("planningWeekDay");
|
||||||
|
const meal = searchParams.get("planningMeal");
|
||||||
|
if (
|
||||||
|
date === null ||
|
||||||
|
!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
|
||||||
|
weekDay === null ||
|
||||||
|
!(WEEK_DAYS as readonly string[]).includes(weekDay) ||
|
||||||
|
meal === null ||
|
||||||
|
!(MEALS as readonly string[]).includes(meal)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { date, weekDay: weekDay as WeekDay, meal: meal as Meal };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Review screen for finalizing an import — routed at
|
* Review screen for finalizing an import — routed at
|
||||||
* `/recettes/importer/:sourceKey/:externalId` (reached from
|
* `/recettes/importer/:sourceKey/:externalId` (reached from
|
||||||
|
|
@ -58,11 +89,24 @@ type LoadState = "loading" | "loaded" | "error";
|
||||||
* Submits to `POST /sources/:sourceKey/import/:externalId`
|
* Submits to `POST /sources/:sourceKey/import/:externalId`
|
||||||
* (`apiClient.importSourceItem`) instead of `POST /recipes` — the only
|
* (`apiClient.importSourceItem`) instead of `POST /recipes` — the only
|
||||||
* other difference from `RecipeFormPage`'s own submit.
|
* other difference from `RecipeFormPage`'s own submit.
|
||||||
|
*
|
||||||
|
* `?planningDate=&planningWeekDay=&planningMeal=` are set only when this
|
||||||
|
* page was reached from `RecipePickerDialog`'s "Sources" tab (via
|
||||||
|
* `SourceItemPreviewPanel`'s import link, see its own `planningSlot` prop)
|
||||||
|
* — picking a not-yet-imported item there hands off to this full review
|
||||||
|
* screen instead of the dialog's own small "how many portions?" step,
|
||||||
|
* since an unresolved-ingredient review doesn't fit in that step. When
|
||||||
|
* present and well-formed, a successful import also adds the freshly
|
||||||
|
* created recipe straight to that planning slot (`POST /planning/items`,
|
||||||
|
* using this form's own `portions` field) before landing back on the
|
||||||
|
* planning page, instead of the recipe's own detail page.
|
||||||
*/
|
*/
|
||||||
export function ImportRecipePage() {
|
export function ImportRecipePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { sourceKey, externalId } = useParams<{ sourceKey: string; externalId: string }>();
|
const { sourceKey, externalId } = useParams<{ sourceKey: string; externalId: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const planningSlot = parsePlanningSlot(searchParams);
|
||||||
|
|
||||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||||
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
||||||
|
|
@ -237,6 +281,27 @@ export function ImportRecipePage() {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const saved = await apiClient.importSourceItem(sourceKey, externalId, result.data);
|
const saved = await apiClient.importSourceItem(sourceKey, externalId, result.data);
|
||||||
|
if (planningSlot) {
|
||||||
|
try {
|
||||||
|
await apiClient.addPlanningItem({
|
||||||
|
date: planningSlot.date,
|
||||||
|
weekDay: planningSlot.weekDay,
|
||||||
|
meal: planningSlot.meal,
|
||||||
|
recipeId: saved.id,
|
||||||
|
portions: Number(portions),
|
||||||
|
});
|
||||||
|
navigate("/");
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// The recipe itself was already imported successfully — only the
|
||||||
|
// planning add failed. Land on the new recipe's own page rather
|
||||||
|
// than stranding the user on a form that already submitted; it
|
||||||
|
// can still be added to that slot afterwards via the normal
|
||||||
|
// "déjà importée" picker path.
|
||||||
|
navigate(`/recettes/${saved.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
navigate(`/recettes/${saved.id}`);
|
navigate(`/recettes/${saved.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||||
|
|
@ -269,6 +334,9 @@ export function ImportRecipePage() {
|
||||||
return (
|
return (
|
||||||
<form className="recipe-form" onSubmit={handleSubmit} noValidate>
|
<form className="recipe-form" onSubmit={handleSubmit} noValidate>
|
||||||
<h1>{t("recipes.sources.import.title")}</h1>
|
<h1>{t("recipes.sources.import.title")}</h1>
|
||||||
|
{planningSlot && (
|
||||||
|
<p className="source-item-preview__hint">{t("recipes.sources.import.planningHint")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<label htmlFor="recipe-name">{t("recipes.form.nameLabel")}</label>
|
<label htmlFor="recipe-name">{t("recipes.form.nameLabel")}</label>
|
||||||
<input id="recipe-name" value={name} onChange={(e) => setName(e.target.value)} />
|
<input id="recipe-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,12 @@ export function RecipesPage() {
|
||||||
<RecipeTabs active={activeTab} onChange={setActiveTab} />
|
<RecipeTabs active={activeTab} onChange={setActiveTab} />
|
||||||
|
|
||||||
{activeTab === "sources" ? (
|
{activeTab === "sources" ? (
|
||||||
<RecipeSourcesPanel onViewImportedRecipe={() => setActiveTab("favoris")} />
|
<RecipeSourcesPanel
|
||||||
|
onSelectImportedRecipe={(recipeId) => {
|
||||||
|
setActiveTab("favoris");
|
||||||
|
navigate(`/recettes/${recipeId}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="recipes-page__catalog">
|
<div className="recipes-page__catalog">
|
||||||
{listState.status === "loading" && (
|
{listState.status === "loading" && (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue