From 8bdbfda3aef546336200b4cb5eac4a574bdf5742 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 19 Aug 2026 14:44:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(planning):=20dialog=20de=20s=C3=A9lection?= =?UTF-8?q?=20de=20recette,=20cr=C3=A9ation=20de=20planning,=20assignation?= =?UTF-8?q?=20avec=20portions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute le chaînon manquant entre le catalogue de recettes et le planning hebdomadaire : - Backend : `PlanningItem.portions` (nouvelle colonne + migration), `POST /planning/items` / `DELETE /planning/items/:id` (créent la semaine de planning à la volée si besoin), `GET /recipes` gagne les filtres `ingredientIds`/`dietIds` (ET) en plus de `suitableForHousehold` (déjà préparé). - Frontend : nouveau `Dialog` générique (premier modal de l'app), `RecipePickerDialog` qui réutilise le même affichage que le catalogue (`RecipeTabs`/`RecipeTable`) avec recherche par nom, filtre ingrédients, filtre régime alimentaire, toggle "convient à tout le foyer", puis une étape de saisie du nombre de portions. - `PlanningPage` : le bouton "+" de chaque case ouvre le dialog, le bouton "✕" retire la recette (optimiste, avec rollback si l'appel échoue), les portions s'affichent sur chaque chip. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 5 --- .../migration.sql | 9 + apps/api/prisma/schema.prisma | 1 + .../src/modules/planning/planning.routes.ts | 49 ++- .../src/modules/planning/planning.service.ts | 112 ++++++- apps/api/src/modules/recipe/recipe.routes.ts | 9 +- apps/api/src/modules/recipe/recipe.service.ts | 102 ++++++- apps/web/src/api/client.ts | 41 ++- apps/web/src/components/ui/Dialog.tsx | 80 +++++ apps/web/src/components/ui/dialog.scss | 63 ++++ .../features/planning/RecipePickerDialog.tsx | 289 ++++++++++++++++++ .../planning/recipe-picker-dialog.scss | 125 ++++++++ apps/web/src/locales/fr/translation.json | 17 +- apps/web/src/pages/PlanningPage.tsx | 110 ++++++- apps/web/src/pages/RecipesPage.tsx | 2 +- packages/shared/src/errors/error-codes.ts | 2 + packages/shared/src/schemas/planning.ts | 22 ++ packages/shared/src/schemas/recipe.ts | 19 +- packages/shared/src/types/planning.ts | 13 +- 18 files changed, 1029 insertions(+), 36 deletions(-) create mode 100644 apps/api/prisma/migrations/20260819064721_planning_item_portions/migration.sql create mode 100644 apps/web/src/components/ui/Dialog.tsx create mode 100644 apps/web/src/components/ui/dialog.scss create mode 100644 apps/web/src/features/planning/RecipePickerDialog.tsx create mode 100644 apps/web/src/features/planning/recipe-picker-dialog.scss diff --git a/apps/api/prisma/migrations/20260819064721_planning_item_portions/migration.sql b/apps/api/prisma/migrations/20260819064721_planning_item_portions/migration.sql new file mode 100644 index 0000000..fc45360 --- /dev/null +++ b/apps/api/prisma/migrations/20260819064721_planning_item_portions/migration.sql @@ -0,0 +1,9 @@ +-- Adds the number of portions to prepare for a planning slot +-- (`PlanningItem.portions`), entered manually by whoever assigns the +-- recipe — no default exists anywhere to derive it from (no such concept +-- on `Recipe` either). Backfills any pre-existing row with 1 portion via a +-- transient DEFAULT, then drops that default so it isn't implicitly +-- reused for new inserts going forward (the app always sends an explicit +-- value, see `addPlanningItemSchema`). +ALTER TABLE "planning_item" ADD COLUMN "portions" INTEGER NOT NULL DEFAULT 1; +ALTER TABLE "planning_item" ALTER COLUMN "portions" DROP DEFAULT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 6b29be4..b7af9b9 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -199,6 +199,7 @@ model PlanningItem { weekDay String @map("week_day") meal String recipeId Int @map("recipe_id") + portions Int planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade) recipe Recipe @relation(fields: [recipeId], references: [id]) diff --git a/apps/api/src/modules/planning/planning.routes.ts b/apps/api/src/modules/planning/planning.routes.ts index 39eb8e2..a4b04f5 100644 --- a/apps/api/src/modules/planning/planning.routes.ts +++ b/apps/api/src/modules/planning/planning.routes.ts @@ -1,10 +1,10 @@ import { parseDateOnly } from "@batch-cooking/date-tools"; import { HttpError } from "@batch-cooking/error-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools"; -import { ErrorCode, getPlanningByDateSchema } from "@batch-cooking/shared"; +import { ErrorCode, addPlanningItemSchema, getPlanningByDateSchema } from "@batch-cooking/shared"; import { Router } from "express"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; -import { getPlanningForDate } from "./planning.service.js"; +import { addPlanningItem, getPlanningForDate, removePlanningItem } from "./planning.service.js"; /** Router mounted at `/planning` in app.ts. */ export const planningRouter = Router(); @@ -34,3 +34,48 @@ planningRouter.get( res.status(200).json(planning); }), ); + +/** Parses and validates the `:id` route param shared by every `/items/:id` route below. */ +function parsePlanningItemId(rawId: string | undefined): number { + const id = Number(rawId); + if (!Number.isInteger(id)) { + throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "id must be an integer"); + } + return id; +} + +/** + * Adds a recipe to one (day, meal) slot of the authenticated user's + * household's planning, creating that week's `Planning` row on the fly if + * needed (see {@link addPlanningItem}). + */ +planningRouter.post( + "/items", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = addPlanningItemSchema.parse(req.body); + const date = parseDateOnly(input.date); + if (date === null) { + throw new HttpError( + 400, + ErrorCode.VALIDATION_ERROR, + `Not a real calendar date: ${input.date}`, + ); + } + + const { id: viewerId, houseId } = res.locals.userProfile; + const item = await addPlanningItem(houseId, viewerId, houseId, date, input); + res.status(201).json(item); + }), +); + +/** Removes one recipe from a planning slot. */ +planningRouter.delete( + "/items/:id", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const id = parsePlanningItemId(req.params.id); + await removePlanningItem(id, res.locals.userProfile.houseId); + res.status(204).end(); + }), +); diff --git a/apps/api/src/modules/planning/planning.service.ts b/apps/api/src/modules/planning/planning.service.ts index 380645e..e548142 100644 --- a/apps/api/src/modules/planning/planning.service.ts +++ b/apps/api/src/modules/planning/planning.service.ts @@ -1,6 +1,13 @@ -import { type DateTime, toDateOnly } from "@batch-cooking/date-tools"; -import type { PlanningView } from "@batch-cooking/shared"; +import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools"; +import { HttpError } from "@batch-cooking/error-tools"; +import { + type AddPlanningItemInput, + ErrorCode, + type PlanningItemView, + type PlanningView, +} from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; +import { assertRecipeVisible } from "../recipe/recipe.service.js"; /** * Finds the household's planning that covers `date` and shapes it into a @@ -12,9 +19,9 @@ import { prisma } from "../../db/prisma.js"; * Returns `null` for two distinct, both entirely normal states — a * `houseId` of `null` (the profile has no household yet — households are no * longer created automatically at signup, see `auth.service.ts`) and "no - * planning row covers this date" (the expected case until planning - * creation is built) — neither is an error, so both collapse to the same - * "nothing to show yet" result rather than throwing. + * planning row covers this date yet" (e.g. a week nobody has added a recipe + * to via {@link addPlanningItem}) — neither is an error, so both collapse + * to the same "nothing to show yet" result rather than throwing. */ export async function getPlanningForDate( houseId: number | null, @@ -59,7 +66,102 @@ export async function getPlanningForDate( id: item.id, weekDay: item.weekDay, meal: item.meal, + portions: item.portions, recipe: item.recipe, })), }; } + +/** + * Finds the household's `Planning` covering the week `weekStart` (a Monday) + * starts, creating it on the fly (spanning the full Monday-to-Sunday week) + * if none exists yet. Matched by exact `startDate`, not a covering range + * like {@link getPlanningForDate} — that query is for "what covers today", + * this one is for "the row `addPlanningItem` should attach to", so it needs + * to land on the same row a second add to the same week would reuse rather + * than risk matching some other overlapping planning. + * + * Not wrapped in a transaction with the `findFirst` — no unique constraint + * exists on `(houseId, startDate)` (see {@link getPlanningForDate}'s doc + * comment on the same gap), so two concurrent first-adds to an empty week + * could each create their own `Planning` row. Accepted at this project's + * scale rather than adding a migration + retry-on-conflict loop for it. + */ +async function findOrCreatePlanningForWeek(houseId: number, weekStart: DateTime) { + const startDate = weekStart.toJSDate(); + const existing = await prisma.planning.findFirst({ where: { houseId, startDate } }); + if (existing) { + return existing; + } + return prisma.planning.create({ + data: { houseId, startDate, finishDate: weekStart.plus({ days: 6 }).toJSDate() }, + }); +} + +/** + * Adds a recipe to one (day, meal) slot of `houseId`'s planning for the + * week containing `date`, creating that week's `Planning` row first if it + * doesn't exist yet (see {@link findOrCreatePlanningForWeek}) — this is the + * only place a `Planning` row gets created at all today, there's no + * separate "create an empty planning" action. `date` is a caller-parsed + * `DateTime` (see `planning.routes.ts`, which validates `input.date` the + * same way `GET /planning` validates its own `?date=`, before calling + * here) rather than the raw `input.date` string — only its week matters, + * not the exact day. + * + * @throws {HttpError} `404 HOUSE_NOT_FOUND` if `houseId` is `null` (the caller has no household). + * @throws {HttpError} `404 RECIPE_NOT_FOUND` if `input.recipeId` doesn't match a recipe visible to `viewerId` — see `recipe.service.ts`'s `assertRecipeVisible`. + */ +export async function addPlanningItem( + houseId: number | null, + viewerId: number, + viewerHouseId: number | null, + date: DateTime, + input: AddPlanningItemInput, +): Promise { + if (houseId === null) { + throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); + } + await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId); + + const weekStart = getWeekStart(toDateOnly(date)); + const planning = await findOrCreatePlanningForWeek(houseId, weekStart); + + const item = await prisma.planningItem.create({ + data: { + planningId: planning.id, + weekDay: input.weekDay, + meal: input.meal, + recipeId: input.recipeId, + portions: input.portions, + }, + include: { recipe: { select: { id: true, name: true } } }, + }); + + return { + id: item.id, + weekDay: item.weekDay, + meal: item.meal, + portions: item.portions, + recipe: item.recipe, + }; +} + +/** + * Removes one planning item outright. The parent `Planning` row is left in + * place even if this was its last item — an empty planning is a normal, + * already-handled state on the read side (`getPlanningForDate`'s grid just + * shows every slot's "+"), not worth a cleanup pass here. + * + * @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` if `id` doesn't match any planning item, or does but belongs to a planning outside `houseId` — never `403`, same "don't confirm what exists" reasoning as `RECIPE_NOT_FOUND` elsewhere. + */ +export async function removePlanningItem(id: number, houseId: number | null): Promise { + const item = await prisma.planningItem.findUnique({ + where: { id }, + include: { planning: true }, + }); + if (!item || houseId === null || item.planning.houseId !== houseId) { + throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`); + } + await prisma.planningItem.delete({ where: { id } }); +} diff --git a/apps/api/src/modules/recipe/recipe.routes.ts b/apps/api/src/modules/recipe/recipe.routes.ts index 989570b..ece4f9e 100644 --- a/apps/api/src/modules/recipe/recipe.routes.ts +++ b/apps/api/src/modules/recipe/recipe.routes.ts @@ -36,7 +36,14 @@ recipeRouter.get( wrapAsyncHandler(async (req, res) => { const input = listRecipesSchema.parse(req.query); const { id: viewerId, houseId } = res.locals.userProfile; - res.status(200).json(await listRecipes(viewerId, houseId, input.tab, input.search)); + res.status(200).json( + await listRecipes(viewerId, houseId, input.tab, { + search: input.search, + suitableForHousehold: input.suitableForHousehold, + ingredientIds: input.ingredientIds, + dietIds: input.dietIds, + }), + ); }), ); diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index b79f4e3..bcb1608 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -140,9 +140,71 @@ function visibleToViewerWhere( }; } +/** + * `Recipe` rows that avoid every member of `houseId`'s declared allergens + * and, for every member with a declared regime, are tagged with that + * regime — the planning recipe picker's "convient à tout le foyer" toggle + * (`suitableForHousehold` on `listRecipes`). Computed server-side (a small + * extra query to gather the household's members' allergy/regime ids) + * rather than exposed to the client as raw per-member data: a member's + * allergies/regime are private the same way visibility already keeps a + * recipe's existence private (404, never 403) — nothing here should let + * one member infer another's medical/dietary info from the shape of a + * filtered list. Deliberately excludes `UserProfileDislikedIngredient` — + * the schema already treats disliked ingredients as a taste preference, + * not a safety constraint (see that model's doc comment), so it doesn't + * belong in a filter framed around what's safe/appropriate to serve. + */ +async function suitableForHouseholdWhere(houseId: number): Promise { + const members = await prisma.userProfile.findMany({ + where: { houseId }, + select: { dietId: true, allergies: { select: { allergyId: true } } }, + }); + const requiredDietIds = [ + ...new Set(members.map((m) => m.dietId).filter((id): id is number => id !== null)), + ]; + const excludedAllergyIds = [ + ...new Set(members.flatMap((m) => m.allergies.map((a) => a.allergyId))), + ]; + + const conditions: Prisma.RecipeWhereInput[] = []; + if (requiredDietIds.length > 0) { + // Every diet declared by a member must be among this recipe's tags — + // not "at least one", since a recipe suiting a vegetarian member + // doesn't automatically suit a gluten-free one too. + conditions.push({ AND: requiredDietIds.map((dietId) => ({ diets: { some: { dietId } } })) }); + } + if (excludedAllergyIds.length > 0) { + conditions.push({ + ingredients: { + none: { ingredient: { allergies: { some: { allergyId: { in: excludedAllergyIds } } } } }, + }, + }); + } + return { AND: conditions }; +} + +/** + * Optional narrowing filters for {@link listRecipes}, on top of the + * mandatory `tab`/`viewerId`/`viewerHouseId` — grouped into one object + * rather than a growing list of positional optional params now that the + * planning recipe picker adds two more on top of `search`/ + * `suitableForHousehold`. + */ +export interface ListRecipesFilters { + /** Case-insensitive name substring. */ + search?: string; + /** The planning recipe picker's "convient à tout le foyer" toggle — see {@link suitableForHouseholdWhere}. */ + suitableForHousehold?: boolean; + /** Recipe must carry *every* one of these ingredient ids (AND, not "any of") — the planning recipe picker's ingredient filter. */ + ingredientIds?: number[]; + /** Recipe must be tagged with *every* one of these diet ids (AND, same reasoning) — the planning recipe picker's regime filter. */ + dietIds?: number[]; +} + /** * The recipes visible to `viewerId` under one catalog tab, alphabetically, - * optionally filtered further by a case-insensitive name substring. No + * optionally filtered further (see {@link ListRecipesFilters}). No * "toutes" tab — every recipe a viewer can see falls under exactly one of * `perso`/`foyer`/`publique` (its own visibility); `favoris` is an * orthogonal, cross-cutting filter on top (and re-applies @@ -153,12 +215,29 @@ export async function listRecipes( viewerId: number, viewerHouseId: number | null, tab: RecipeTab, - search?: string, + filters: ListRecipesFilters = {}, ): Promise { + const { search, suitableForHousehold, ingredientIds, dietIds } = filters; const conditions: Prisma.RecipeWhereInput[] = []; if (search) { conditions.push({ name: { contains: search, mode: "insensitive" } }); } + // No-op without a household — nothing to filter against, same posture as + // the `foyer` tab returning everything it can rather than throwing. + if (suitableForHousehold && viewerHouseId !== null) { + conditions.push(await suitableForHouseholdWhere(viewerHouseId)); + } + if (ingredientIds && ingredientIds.length > 0) { + // One condition per required id (AND) — a recipe must carry all of + // them, not just one, same "every one, not any one" posture as + // suitableForHouseholdWhere's requiredDietIds. + conditions.push({ + AND: ingredientIds.map((ingredientId) => ({ ingredients: { some: { ingredientId } } })), + }); + } + if (dietIds && dietIds.length > 0) { + conditions.push({ AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })) }); + } switch (tab) { case "favoris": @@ -360,6 +439,25 @@ export async function removeFavorite(id: number, viewerId: number): Promise { + const recipe = await findRecipeOrThrow(id, viewerId); + if (!canView(recipe, viewerId, viewerHouseId)) { + throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`); + } +} + /** Re-fetches a recipe by id (with {@link recipeInclude}), or throws `404 RECIPE_NOT_FOUND` — the shared "load or reject" step for every recipe endpoint. Does *not* check visibility on its own — callers combine it with {@link canView} (read paths) or {@link assertIsAuthor} (write paths). */ async function findRecipeOrThrow(id: number, viewerId: number): Promise { const recipe = await prisma.recipe.findUnique({ diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 2852c70..6776771 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1,4 +1,5 @@ import { + type AddPlanningItemInput, type AllergyView, type ApiErrorResponse, type CreateRecipeInput, @@ -7,6 +8,7 @@ import { type HouseView, type IngredientView, type LoginInput, + type PlanningItemView, type PlanningView, type PreferencesView, type RecipeSummaryView, @@ -128,6 +130,22 @@ export class ApiClient { return this.request(`/planning?date=${date}`); } + /** + * Adds a recipe to one (day, meal) slot of the household's planning for + * the week containing `input.date`, creating that week's planning on the + * fly if it doesn't exist yet — rejects with `HOUSE_NOT_FOUND` (no + * household) or `RECIPE_NOT_FOUND` (the recipe isn't visible to the + * caller). + */ + public addPlanningItem(input: AddPlanningItemInput): Promise { + return this.request("/planning/items", { method: "POST", body: JSON.stringify(input) }); + } + + /** Removes one recipe from a planning slot — rejects with `PLANNING_ITEM_NOT_FOUND`. */ + public removePlanningItem(id: number): Promise { + return this.request(`/planning/items/${id}`, { method: "DELETE" }); + } + /** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */ public getDiets(): Promise { return this.request("/reference/diets"); @@ -143,10 +161,27 @@ export class ApiClient { return this.request("/reference/ingredients"); } - /** One catalog tab (favoris/perso/foyer/publique — see `RecipeTab`), optionally filtered further by a name substring. */ - public listRecipes(tab: RecipeTab, search?: string): Promise { + /** + * One catalog tab (favoris/perso/foyer/publique — see `RecipeTab`), + * optionally narrowed further — `search` (name substring), + * `suitableForHousehold` (the planning recipe picker's "convient à tout + * le foyer" toggle), `ingredientIds`/`dietIds` (that same picker's + * ingredient/regime filters — a recipe must carry *every* id listed). + */ + public listRecipes( + tab: RecipeTab, + filters: { + search?: string; + suitableForHousehold?: boolean; + ingredientIds?: number[]; + dietIds?: number[]; + } = {}, + ): Promise { const params = new URLSearchParams({ tab }); - if (search) params.set("search", search); + if (filters.search) params.set("search", filters.search); + if (filters.suitableForHousehold) params.set("suitableForHousehold", "true"); + for (const id of filters.ingredientIds ?? []) params.append("ingredientIds", String(id)); + for (const id of filters.dietIds ?? []) params.append("dietIds", String(id)); return this.request(`/recipes?${params.toString()}`); } diff --git a/apps/web/src/components/ui/Dialog.tsx b/apps/web/src/components/ui/Dialog.tsx new file mode 100644 index 0000000..ae1790d --- /dev/null +++ b/apps/web/src/components/ui/Dialog.tsx @@ -0,0 +1,80 @@ +import { type ReactNode, useEffect, useRef } from "react"; +import "./dialog.scss"; + +/** + * App-wide modal primitive — full-screen backdrop + a centered panel, + * closing on Escape or an outside click (same `mousedown`-outside pattern + * as `PlanningPage.tsx`'s `CalendarPopover`, generalized here instead of + * duplicated a third time). First modal in the app — every other + * "confirm/cancel" surface so far (`RecipeDetailPanel`'s delete button, + * the settings pages' danger zones) is an inline two-step reveal, not an + * overlay; a full recipe catalog + filters (`RecipePickerDialog`) doesn't + * fit inline, hence this. + * + * Renders nothing while `isOpen` is `false` — callers don't need to guard + * mounting it themselves. + */ +export function Dialog({ + isOpen, + onClose, + title, + children, + className, +}: { + isOpen: boolean; + onClose: () => void; + title?: string; + children: ReactNode; + className?: string; +}) { + const panelRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {title && ( +
+

{title}

+ +
+ )} +
{children}
+
+
+ ); +} diff --git a/apps/web/src/components/ui/dialog.scss b/apps/web/src/components/ui/dialog.scss new file mode 100644 index 0000000..2b4d315 --- /dev/null +++ b/apps/web/src/components/ui/dialog.scss @@ -0,0 +1,63 @@ +// Modal overlay + panel — see Dialog.tsx. Colocated here rather than in +// global.scss since it's one component's styling, same convention as every +// feature's own .scss file (recipes.scss, planning-page.scss, …). + +.dialog-overlay { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-lg); + background: rgba(0, 0, 0, 0.45); +} + +.dialog-panel { + display: flex; + flex-direction: column; + width: 100%; + max-width: 48rem; + max-height: calc(100vh - var(--space-2xl)); + background: var(--color-surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + overflow: hidden; +} + +.dialog-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding: var(--space-md) var(--space-lg); + border-bottom: 1px solid var(--color-border); + + h2 { + font-size: var(--font-size-lg); + } +} + +.dialog-panel__close { + flex: none; + width: 2rem; + height: 2rem; + border: none; + border-radius: var(--radius-base); + background: transparent; + color: var(--color-text-muted); + font-size: var(--font-size-md); + cursor: pointer; + + &:hover { + background: var(--color-surface-alt); + color: var(--color-text); + } +} + +.dialog-panel__body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: var(--space-lg); +} diff --git a/apps/web/src/features/planning/RecipePickerDialog.tsx b/apps/web/src/features/planning/RecipePickerDialog.tsx new file mode 100644 index 0000000..7f74fc2 --- /dev/null +++ b/apps/web/src/features/planning/RecipePickerDialog.tsx @@ -0,0 +1,289 @@ +import { + ErrorCode, + type DietView, + type IngredientView, + type Meal, + type PlanningItemView, + type RecipeSummaryView, + type RecipeTab, + 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 { RecipeTable } from "../recipes/RecipeTable"; +import { RecipeTabs } 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. + * + * 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. + */ +export function RecipePickerDialog({ + slot, + onClose, + onAdded, +}: { + slot: PlanningSlot; + onClose: () => void; + onAdded: (item: PlanningItemView) => void; +}) { + const { t } = useTranslation(); + + const [activeTab, setActiveTab] = useState("favoris"); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [selectedIngredientIds, setSelectedIngredientIds] = useState([]); + const [selectedDietIds, setSelectedDietIds] = useState([]); + const [suitableForHousehold, setSuitableForHousehold] = useState(false); + const [hasHousehold, setHasHousehold] = useState(false); + const [isIngredientPickerOpen, setIsIngredientPickerOpen] = useState(false); + + const [ingredientsCatalog, setIngredientsCatalog] = useState([]); + const [dietsCatalog, setDietsCatalog] = useState([]); + const [listState, setListState] = useState({ status: "loading" }); + + // 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(null); + const [portions, setPortions] = useState("1"); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(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(() => { + 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), + ); + + 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 ( + +
+ + setPortions(e.target.value)} + autoFocus + /> + {submitError &&

{submitError}

} +
+ + +
+
+
+ ); + } + + return ( + +
+ setSearch(e.target.value)} + /> + +
+ + {t("planning.picker.ingredientsFilterLabel")} + +
+ {selectedIngredients.map((ingredient) => ( + + {t(`catalog.ingredients.${ingredient.key}`)} + + + ))} + +
+ {isIngredientPickerOpen && ( + + setSelectedIngredientIds((ids) => [...ids, ingredient.id]) + } + /> + )} +
+ + + + {hasHousehold && ( + + {t("planning.picker.suitableForHouseholdLabel")} + + )} +
+ + + + {listState.status === "loading" && ( +

{t("planning.picker.loading")}

+ )} + {listState.status === "error" && ( +

+ {t("common.loadError")} +

+ )} + {listState.status === "loaded" && listState.recipes.length === 0 && ( +

{t("planning.picker.empty")}

+ )} + {listState.status === "loaded" && listState.recipes.length > 0 && ( + + setSelectedRecipe(listState.recipes.find((recipe) => recipe.id === id) ?? null) + } + /> + )} +
+ ); +} diff --git a/apps/web/src/features/planning/recipe-picker-dialog.scss b/apps/web/src/features/planning/recipe-picker-dialog.scss new file mode 100644 index 0000000..7eb5d19 --- /dev/null +++ b/apps/web/src/features/planning/recipe-picker-dialog.scss @@ -0,0 +1,125 @@ +// RecipePickerDialog — the dialog panel itself is styled generically by +// Dialog.tsx's dialog.scss; only this feature's own filter bar / chips / +// portions-confirmation step live here. `.recipes-page__status` and +// `.field-error` come from recipes.scss (already loaded — RecipeTable / +// RecipeTabs import it themselves). + +.recipe-picker-dialog { + max-width: 56rem; +} + +.recipe-picker__filters { + display: flex; + flex-direction: column; + gap: var(--space-md); + margin-bottom: var(--space-md); +} + +.recipe-picker__search { + width: 100%; + padding: var(--space-sm) var(--space-md); + border: 1.5px solid var(--color-border); + border-radius: var(--radius-base); + background: var(--color-surface); + color: var(--color-text); + font-size: var(--font-size-base); +} + +.recipe-picker__filter-label { + display: block; + margin-bottom: var(--space-xs); + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--color-text-muted); +} + +.recipe-picker__chips { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-xs); +} + +.filter-chip { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface)); + color: var(--color-primary); + font-size: var(--font-size-sm); + font-weight: 600; + + button { + border: none; + background: transparent; + color: inherit; + cursor: pointer; + padding: 0; + font-size: var(--font-size-xs); + } +} + +.recipe-picker__toggle-ingredient-picker { + padding: var(--space-xs) var(--space-sm); + border: 1.5px dashed var(--color-border); + border-radius: var(--radius-pill); + background: transparent; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + cursor: pointer; + + &:hover { + border-color: var(--color-primary); + color: var(--color-primary); + } +} + +// --- Portions confirmation step ----------------------------------------- + +.recipe-picker-confirm { + display: flex; + flex-direction: column; + gap: var(--space-sm); + + label { + font-weight: 600; + } + + input[type="number"] { + padding: var(--space-sm) var(--space-md); + border: 1.5px solid var(--color-border); + border-radius: var(--radius-base); + background: var(--color-surface); + color: var(--color-text); + font-size: var(--font-size-base); + max-width: 8rem; + } +} + +.recipe-picker-confirm__actions { + display: flex; + justify-content: flex-end; + gap: var(--space-sm); + margin-top: var(--space-sm); +} + +.recipe-picker-confirm__confirm { + background: var(--color-primary); + color: var(--color-surface); + border: none; + border-radius: var(--radius-base); + padding: var(--space-sm) var(--space-md); + font-weight: 600; + cursor: pointer; + + &:hover { + background: var(--color-primary-hover); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 83edfca..b512aac 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -118,8 +118,23 @@ "diner": "Dîner" }, "grid": { - "addRecipeSoon": "Recherche de recettes à venir", + "addRecipe": "Ajouter une recette", "removeRecipe": "Retirer cette recette" + }, + "picker": { + "title": "Choisir une recette", + "searchPlaceholder": "Rechercher une recette…", + "ingredientsFilterLabel": "Ingrédients", + "addIngredientFilter": "Ajouter un ingrédient", + "hideIngredientPicker": "Masquer", + "suitableForHouseholdLabel": "Convient à tout le foyer", + "loading": "Chargement des recettes…", + "empty": "Aucune recette ne correspond à ces filtres.", + "confirmTitle": "Ajouter {{recipe}}", + "portionsLabel": "Nombre de portions", + "backButton": "Retour", + "confirmButton": "Ajouter au planning", + "adding": "Ajout…" } }, "recipes": { diff --git a/apps/web/src/pages/PlanningPage.tsx b/apps/web/src/pages/PlanningPage.tsx index 749d862..0771e23 100644 --- a/apps/web/src/pages/PlanningPage.tsx +++ b/apps/web/src/pages/PlanningPage.tsx @@ -6,10 +6,17 @@ import { getWeekStart, toDateOnly, } from "@batch-cooking/date-tools"; -import { MEALS, type Meal, type PlanningView, WEEK_DAYS } from "@batch-cooking/shared"; +import { + MEALS, + type Meal, + type PlanningItemView, + type PlanningView, + WEEK_DAYS, +} from "@batch-cooking/shared"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { apiClient } from "../api/client"; +import { type PlanningSlot, RecipePickerDialog } from "../features/planning/RecipePickerDialog"; import "./planning-page.scss"; /** Load state for the `GET /planning` call — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented. */ @@ -38,6 +45,12 @@ export function PlanningPage() { const { t } = useTranslation(); const [weekStart, setWeekStart] = useState(() => getWeekStart(DateTime.utc())); const [state, setState] = useState({ status: "loading" }); + // The slot a `RecipePickerDialog` is currently open for — `null` means + // closed. Mounting the dialog only while this is set (rather than an + // always-mounted `isOpen` toggle) resets its internal filter/search + // state for free on every open, same convention as `WeekNavigator`'s own + // `CalendarPopover` below. + const [openSlot, setOpenSlot] = useState(null); useEffect(() => { let cancelled = false; @@ -57,6 +70,43 @@ export function PlanningPage() { }; }, [weekStart]); + /** + * Applies `updater` to the currently loaded planning's items, patching + * local state without a full `GET /planning` refetch — same philosophy + * as `RecipesPage`'s `handleFavoriteToggled`/`handleDeleted`. Builds a + * placeholder parent `Planning` if none existed yet (the week's very + * first add, see `addPlanningItem`'s `findOrCreatePlanningForWeek`) — + * its `id`/`startDate`/`finishDate` are never read anywhere on this page + * (only `planning.items` is), so a placeholder id is harmless; the next + * week-navigation away and back re-fetches the real row anyway. + */ + function patchPlanningItems(updater: (items: PlanningItemView[]) => PlanningItemView[]) { + setState((prev) => { + if (prev.status !== "loaded") return prev; + const planning = prev.planning ?? { + id: -1, + startDate: weekStart.toISO() ?? "", + finishDate: weekStart.plus({ days: 6 }).toISO() ?? "", + items: [], + }; + return { status: "loaded", planning: { ...planning, items: updater(planning.items) } }; + }); + } + + function handleAdded(item: PlanningItemView) { + patchPlanningItems((items) => [...items, item]); + } + + /** Optimistic removal (same rollback-on-failure idea as `FavoriteStarButton`) — the API call already happened by the time this needs to roll back, `removePlanningItem` having already rejected. */ + async function handleRemove(item: PlanningItemView) { + patchPlanningItems((items) => items.filter((i) => i.id !== item.id)); + try { + await apiClient.removePlanningItem(item.id); + } catch { + patchPlanningItems((items) => [...items, item]); + } + } + return (
@@ -75,7 +125,20 @@ export function PlanningPage() { )} {state.status === "loaded" && ( - + + )} + + {openSlot && ( + setOpenSlot(null)} + onAdded={handleAdded} + /> )}
); @@ -239,7 +302,14 @@ function CalendarPopover({ function PlanningGrid({ weekStart, planning, -}: { weekStart: DateTime; planning: PlanningView | null }) { + onAddSlot, + onRemoveItem, +}: { + weekStart: DateTime; + planning: PlanningView | null; + onAddSlot: (slot: PlanningSlot) => void; + onRemoveItem: (item: PlanningItemView) => void; +}) { const { t } = useTranslation(); const today = toDateOnly(DateTime.utc()); const days = WEEK_DAYS.map((weekDay, i) => ({ weekDay, date: weekStart.plus({ days: i }) })); @@ -267,9 +337,9 @@ function PlanningGrid({ item.weekDay === weekDay && item.meal === meal) - .map((item) => ({ id: item.id, name: item.recipe.name }))} + items={items.filter((item) => item.weekDay === weekDay && item.meal === meal)} + onAdd={() => onAddSlot({ date: formatDateOnly(date), weekDay, meal })} + onRemove={onRemoveItem} /> ))} @@ -280,28 +350,35 @@ function PlanningGrid({ ); } -/** One (day, meal) cell: the recipes already planned for it (as pills) plus the "+" to add another. */ +/** One (day, meal) cell: the recipes already planned for it (as pills, each showing its portion count) plus the "+" to add another. */ function MealCell({ isToday, - recipes, + items, + onAdd, + onRemove, }: { isToday: boolean; - recipes: { id: number; name: string }[]; + items: PlanningItemView[]; + onAdd: () => void; + onRemove: (item: PlanningItemView) => void; }) { const { t } = useTranslation(); return (
- {recipes.length > 0 && ( + {items.length > 0 && (
- {recipes.map((recipe) => ( - - {recipe.name} + {items.map((item) => ( + + + {item.recipe.name} · ×{item.portions} + @@ -309,7 +386,12 @@ function MealCell({ ))}
)} -
diff --git a/apps/web/src/pages/RecipesPage.tsx b/apps/web/src/pages/RecipesPage.tsx index 0b9ffe3..3bff984 100644 --- a/apps/web/src/pages/RecipesPage.tsx +++ b/apps/web/src/pages/RecipesPage.tsx @@ -48,7 +48,7 @@ export function RecipesPage() { setListState({ status: "loading" }); apiClient - .listRecipes(activeTab, debouncedSearch.trim() || undefined) + .listRecipes(activeTab, { search: debouncedSearch.trim() || undefined }) .then((recipes) => { if (!cancelled) setListState({ status: "loaded", recipes }); }) diff --git a/packages/shared/src/errors/error-codes.ts b/packages/shared/src/errors/error-codes.ts index 04bfbea..2f6f409 100644 --- a/packages/shared/src/errors/error-codes.ts +++ b/packages/shared/src/errors/error-codes.ts @@ -56,6 +56,8 @@ export enum ErrorCode { RECIPE_NOT_FOUND = 4045, /** A recipe payload's `ingredientId` doesn't match any reference `Ingredient` row. */ INGREDIENT_NOT_FOUND = 4046, + /** `DELETE /planning/items/:id` given an id that doesn't match any planning item visible to the caller's household. */ + PLANNING_ITEM_NOT_FOUND = 4047, /** Unexpected/unhandled failure — the catch-all, always logged server-side. */ INTERNAL_ERROR = 5000, } diff --git a/packages/shared/src/schemas/planning.ts b/packages/shared/src/schemas/planning.ts index 31a28ec..817368b 100644 --- a/packages/shared/src/schemas/planning.ts +++ b/packages/shared/src/schemas/planning.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { MEALS, WEEK_DAYS } from "../types/planning.js"; // See schemas/auth.ts for the shared client/server validation rationale. @@ -15,3 +16,24 @@ export const getPlanningByDateSchema = z.object({ }); /** Inferred TS type for {@link getPlanningByDateSchema}'s validated output. */ export type GetPlanningByDateInput = z.infer; + +/** + * Payload accepted by `POST /planning/items` — adds a recipe to one + * (day, meal) slot of the household's planning for the week containing + * `date`, creating that week's `Planning` row on the fly if it doesn't + * exist yet (see `planning.service.ts`'s `addPlanningItem`). `weekDay`/ + * `meal` are finally validated against a closed set here — until now + * `WEEK_DAYS`/`MEALS` were only a documented convention (see their doc + * comments in `types/planning.ts`), never enforced, since no write endpoint + * existed yet. + */ +export const addPlanningItemSchema = z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date invalide"), + weekDay: z.enum(WEEK_DAYS), + meal: z.enum(MEALS), + recipeId: z.number().int().positive(), + /** How many portions to prepare for this slot — a plain manual entry, see `PlanningItemView.portions`. */ + portions: z.number().int().positive("Le nombre de portions doit être positif"), +}); +/** Inferred TS type for {@link addPlanningItemSchema}'s validated output. */ +export type AddPlanningItemInput = z.infer; diff --git a/packages/shared/src/schemas/recipe.ts b/packages/shared/src/schemas/recipe.ts index 8bf2e01..7354dfb 100644 --- a/packages/shared/src/schemas/recipe.ts +++ b/packages/shared/src/schemas/recipe.ts @@ -65,10 +65,27 @@ export const recipeTabSchema = z.enum(["favoris", "perso", "foyer", "publique"]) /** Inferred TS type for {@link recipeTabSchema}'s validated output. */ export type RecipeTab = z.infer; -/** Payload accepted by `GET /recipes`'s query params — `tab` selects the catalog tab, `search` optionally filters it further by name substring. */ +/** + * A repeated query param (`?ingredientIds=1&ingredientIds=2`) arrives via + * Express/`qs` as a plain string when there's exactly one, or a string + * array when there's more than one — never a bare array for a single + * value. Normalizes both (plus the "absent" case) into `number[] | + * undefined` before the real `z.array` check runs. + */ +function queryIdArray() { + return z.preprocess( + (value) => (value === undefined ? undefined : Array.isArray(value) ? value : [value]), + z.array(z.coerce.number().int().positive()).optional(), + ); +} + +/** Payload accepted by `GET /recipes`'s query params — `tab` selects the catalog tab, `search` optionally filters it further by name substring, `suitableForHousehold` (the planning recipe picker's "convient à tout le foyer" toggle) further restricts to recipes that avoid every household member's declared allergens and match every member's declared regime (see `recipe.service.ts`'s `listRecipes`) — a no-op if the caller has no household. `ingredientIds`/`dietIds` (the same picker's ingredient/regime filters) further restrict to recipes carrying *every* id listed (AND, not "any of") — see {@link queryIdArray}. `z.coerce.boolean()` since query params always arrive as strings. */ export const listRecipesSchema = z.object({ tab: recipeTabSchema, search: z.string().trim().min(1).optional(), + suitableForHousehold: z.coerce.boolean().optional(), + ingredientIds: queryIdArray(), + dietIds: queryIdArray(), }); /** Inferred TS type for {@link listRecipesSchema}'s validated output. */ export type ListRecipesInput = z.infer; diff --git a/packages/shared/src/types/planning.ts b/packages/shared/src/types/planning.ts index 271b75e..e50de89 100644 --- a/packages/shared/src/types/planning.ts +++ b/packages/shared/src/types/planning.ts @@ -1,10 +1,10 @@ /** * The 7 values `PlanningItemView.weekDay` is expected to take — lowercase, * unaccented French day names. Not enforced by the database (`week_day` is - * a plain `String` column, see schema.prisma) or by any write endpoint yet - * (there isn't one), but this is the contract the planning grid - * (`apps/web`'s `PlanningPage`) reads against, and the one a future - * "add a recipe to a slot" endpoint should write. + * a plain `String` column, see schema.prisma), but enforced at the write + * boundary by `schemas/planning.ts`'s `addPlanningItemSchema` (`POST + * /planning/items`) — this is the contract the planning grid (`apps/web`'s + * `PlanningPage`) reads against, and the one that endpoint writes. */ export const WEEK_DAYS = [ "lundi", @@ -20,8 +20,7 @@ export type WeekDay = (typeof WEEK_DAYS)[number]; /** * The 5 values `PlanningItemView.meal` is expected to take, in day order — - * same "documented but not enforced yet" status as {@link WEEK_DAYS}, same - * reason. + * same enforcement story as {@link WEEK_DAYS}. */ export const MEALS = ["petit-dejeuner", "collation", "dejeuner", "gouter", "diner"] as const; /** Inferred TS type for one {@link MEALS} member. */ @@ -38,6 +37,8 @@ export interface PlanningItemView { weekDay: string; /** Which meal of the day this item is for — see {@link Meal} (free-form for now, same reason). */ meal: string; + /** How many portions to prepare for this slot — entered by whoever assigns the recipe (`addPlanningItemSchema`'s `portions`), not derived from any recipe default (no such default exists, see `Recipe` in schema.prisma). */ + portions: number; recipe: { id: number; name: string;