Merge pull request #26 from kyuno053/feat/planning-recipe-picker
feat(planning): dialog de sélection de recette + assignation avec portions
This commit is contained in:
commit
a8eea46c58
21 changed files with 1076 additions and 40 deletions
|
|
@ -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;
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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<unknown, AuthLocals>(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<unknown, AuthLocals>(async (req, res) => {
|
||||
const id = parsePlanningItemId(req.params.id);
|
||||
await removePlanningItem(id, res.locals.userProfile.houseId);
|
||||
res.status(204).end();
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<PlanningItemView> {
|
||||
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<void> {
|
||||
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 } });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,14 @@ recipeRouter.get(
|
|||
wrapAsyncHandler<unknown, AuthLocals>(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,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Prisma.RecipeWhereInput> {
|
||||
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<RecipeSummaryView[]> {
|
||||
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<void
|
|||
await prisma.recipeFavorite.deleteMany({ where: { userProfileId: viewerId, recipeId: id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard for other modules that need to confirm a recipe is visible to a
|
||||
* viewer before referencing it (e.g. `planning.service.ts`'s
|
||||
* `addPlanningItem`, before creating a `PlanningItem` pointing at it) —
|
||||
* exported rather than duplicating {@link canView} at the call site.
|
||||
*
|
||||
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe, or does but isn't visible to `viewerId` — never `403`, same reasoning as `getRecipe`.
|
||||
*/
|
||||
export async function assertRecipeVisible(
|
||||
id: number,
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<void> {
|
||||
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<RecipeWithDetails> {
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
|
|
|
|||
|
|
@ -108,7 +108,13 @@ describe("Planning", () => {
|
|||
},
|
||||
});
|
||||
await prisma.planningItem.create({
|
||||
data: { planningId: planning.id, weekDay: "lundi", meal: "diner", recipeId: recipe.id },
|
||||
data: {
|
||||
planningId: planning.id,
|
||||
weekDay: "lundi",
|
||||
meal: "diner",
|
||||
recipeId: recipe.id,
|
||||
portions: 4,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await agent.get("/planning").query({ date: today() });
|
||||
|
|
@ -159,7 +165,13 @@ describe("Planning", () => {
|
|||
},
|
||||
});
|
||||
await prisma.planningItem.create({
|
||||
data: { planningId: planning.id, weekDay: "mardi", meal: "dejeuner", recipeId: recipe.id },
|
||||
data: {
|
||||
planningId: planning.id,
|
||||
weekDay: "mardi",
|
||||
meal: "dejeuner",
|
||||
recipeId: recipe.id,
|
||||
portions: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await agent.get("/planning").query({ date: isoDate(nextWeek) });
|
||||
|
|
|
|||
|
|
@ -461,7 +461,13 @@ describe("Recipes", () => {
|
|||
},
|
||||
});
|
||||
await prisma.planningItem.create({
|
||||
data: { planningId: planning.id, weekDay: "lundi", meal: "diner", recipeId: recipe.id },
|
||||
data: {
|
||||
planningId: planning.id,
|
||||
weekDay: "lundi",
|
||||
meal: "diner",
|
||||
recipeId: recipe.id,
|
||||
portions: 4,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await agent.delete(`/recipes/${recipe.id}`);
|
||||
|
|
|
|||
|
|
@ -82,11 +82,18 @@ describe("Planning grid", () => {
|
|||
startDate: "2026-08-17T00:00:00.000Z",
|
||||
finishDate: "2026-08-23T00:00:00.000Z",
|
||||
items: [
|
||||
{ id: 1, weekDay: "mardi", meal: "diner", recipe: { id: 1, name: "Ratatouille" } },
|
||||
{
|
||||
id: 1,
|
||||
weekDay: "mardi",
|
||||
meal: "diner",
|
||||
portions: 4,
|
||||
recipe: { id: 1, name: "Ratatouille" },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
weekDay: "mercredi",
|
||||
meal: "dejeuner",
|
||||
portions: 2,
|
||||
recipe: { id: 2, name: "Curry de lentilles" },
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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<PlanningItemView> {
|
||||
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<void> {
|
||||
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<DietView[]> {
|
||||
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<RecipeSummaryView[]> {
|
||||
/**
|
||||
* 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<RecipeSummaryView[]> {
|
||||
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()}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
104
apps/web/src/components/ui/Dialog.tsx
Normal file
104
apps/web/src/components/ui/Dialog.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
import "./dialog.scss";
|
||||
|
||||
/**
|
||||
* App-wide modal primitive — a native `<dialog>` (`showModal()`), not a
|
||||
* `role="dialog"` div: the browser handles the modal semantics, the
|
||||
* focus trap, Escape-to-close and the backdrop for free instead of this
|
||||
* component reimplementing all four. 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.
|
||||
*
|
||||
* Mounted only while open (see `PlanningPage`'s conditional rendering of
|
||||
* `RecipePickerDialog`, same convention as its own `CalendarPopover`) —
|
||||
* `showModal()` fires once on mount rather than toggling on an `isOpen`
|
||||
* prop, since closing this component means the caller stops rendering it
|
||||
* rather than flipping a prop on a permanently-mounted instance.
|
||||
*/
|
||||
export function Dialog({
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
dialogRef.current?.showModal();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
// `close` covers every way a native dialog can close — Escape (which
|
||||
// fires `cancel` first, then `close`) as much as a future
|
||||
// `<form method="dialog">` — so this is the one listener needed to
|
||||
// keep the caller's own "is this open" state (e.g. `PlanningPage`'s
|
||||
// `openSlot`) in sync with it.
|
||||
dialog.addEventListener("close", onClose);
|
||||
return () => dialog.removeEventListener("close", onClose);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
// Attached imperatively (not a JSX `onClick`) since the click-to-close
|
||||
// it implements is already reachable from the keyboard via Escape
|
||||
// (native `cancel`/`close`, wired above) — a JSX `onClick` here would
|
||||
// trip the "needs a matching keyboard handler" a11y lint for a
|
||||
// non-interactive element even though one already exists, just not in
|
||||
// a form that lint rule can see.
|
||||
function handleClick(e: MouseEvent) {
|
||||
// Re-read from the ref (not the outer `dialog` const) — TS can't
|
||||
// carry that early-return narrowing into a nested function, since it
|
||||
// can't prove the function won't run at some later point where it no
|
||||
// longer holds (even though here, as an event listener on this same
|
||||
// element, it trivially still does).
|
||||
const current = dialogRef.current;
|
||||
if (!current) return;
|
||||
// A click lands on the `<dialog>` element itself both for the
|
||||
// backdrop *and* for its own unfilled padding/margin — comparing
|
||||
// against its content box (not just `e.target`) is what actually
|
||||
// distinguishes "outside the panel" from "on it".
|
||||
const rect = current.getBoundingClientRect();
|
||||
const inside =
|
||||
e.clientX >= rect.left &&
|
||||
e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top &&
|
||||
e.clientY <= rect.bottom;
|
||||
if (!inside) current.close();
|
||||
}
|
||||
dialog.addEventListener("click", handleClick);
|
||||
return () => dialog.removeEventListener("click", handleClick);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className={["dialog-panel", className].filter(Boolean).join(" ")}
|
||||
aria-label={title}
|
||||
>
|
||||
{title && (
|
||||
<div className="dialog-panel__header">
|
||||
<h2>{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="dialog-panel__close"
|
||||
onClick={() => dialogRef.current?.close()}
|
||||
aria-label="Fermer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="dialog-panel__body">{children}</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
63
apps/web/src/components/ui/dialog.scss
Normal file
63
apps/web/src/components/ui/dialog.scss
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// Modal panel — see Dialog.tsx. A native <dialog> opened via showModal(),
|
||||
// so the browser supplies the backdrop/centering/focus-trap; this only
|
||||
// resets its default UA styling (border, padding, colors) and layers the
|
||||
// header/body layout on top. 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-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 48rem;
|
||||
max-height: calc(100vh - var(--space-2xl));
|
||||
margin: auto; // UA default for a shown <dialog>, kept explicit
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
|
||||
&::backdrop {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
283
apps/web/src/features/planning/RecipePickerDialog.tsx
Normal file
283
apps/web/src/features/planning/RecipePickerDialog.tsx
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import {
|
||||
type DietView,
|
||||
ErrorCode,
|
||||
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<RecipeTab>("favoris");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [selectedIngredientIds, setSelectedIngredientIds] = useState<number[]>([]);
|
||||
const [selectedDietIds, setSelectedDietIds] = useState<number[]>([]);
|
||||
const [suitableForHousehold, setSuitableForHousehold] = useState(false);
|
||||
const [hasHousehold, setHasHousehold] = useState(false);
|
||||
const [isIngredientPickerOpen, setIsIngredientPickerOpen] = useState(false);
|
||||
|
||||
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
||||
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
||||
const [listState, setListState] = useState<ListState>({ 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<RecipeSummaryView | null>(null);
|
||||
const [portions, setPortions] = useState("1");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(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 (
|
||||
<Dialog
|
||||
onClose={onClose}
|
||||
title={t("planning.picker.confirmTitle", { recipe: selectedRecipe.name })}
|
||||
>
|
||||
<div className="recipe-picker-confirm">
|
||||
<label htmlFor="planning-picker-portions">{t("planning.picker.portionsLabel")}</label>
|
||||
<input
|
||||
id="planning-picker-portions"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={portions}
|
||||
onChange={(e) => setPortions(e.target.value)}
|
||||
/>
|
||||
{submitError && <p className="field-error">{submitError}</p>}
|
||||
<div className="recipe-picker-confirm__actions">
|
||||
<button type="button" onClick={() => setSelectedRecipe(null)} disabled={isSubmitting}>
|
||||
{t("planning.picker.backButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="recipe-picker-confirm__confirm"
|
||||
onClick={handleConfirm}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t("planning.picker.adding") : t("planning.picker.confirmButton")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onClose={onClose} title={t("planning.picker.title")} className="recipe-picker-dialog">
|
||||
<div className="recipe-picker__filters">
|
||||
<input
|
||||
type="search"
|
||||
className="recipe-picker__search"
|
||||
placeholder={t("planning.picker.searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="recipe-picker__ingredient-filter">
|
||||
<span className="recipe-picker__filter-label">
|
||||
{t("planning.picker.ingredientsFilterLabel")}
|
||||
</span>
|
||||
<div className="recipe-picker__chips">
|
||||
{selectedIngredients.map((ingredient) => (
|
||||
<span key={ingredient.id} className="filter-chip">
|
||||
{t(`catalog.ingredients.${ingredient.key}`)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelectedIngredientIds((ids) => ids.filter((id) => id !== ingredient.id))
|
||||
}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="recipe-picker__toggle-ingredient-picker"
|
||||
onClick={() => setIsIngredientPickerOpen((open) => !open)}
|
||||
aria-expanded={isIngredientPickerOpen}
|
||||
>
|
||||
+{" "}
|
||||
{isIngredientPickerOpen
|
||||
? t("planning.picker.hideIngredientPicker")
|
||||
: t("planning.picker.addIngredientFilter")}
|
||||
</button>
|
||||
</div>
|
||||
{isIngredientPickerOpen && (
|
||||
<IngredientPicker
|
||||
ingredients={ingredientsCatalog}
|
||||
excludeIds={selectedIngredientIds}
|
||||
onSelect={(ingredient) => setSelectedIngredientIds((ids) => [...ids, ingredient.id])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DietTagSelect diets={dietsCatalog} value={selectedDietIds} onChange={setSelectedDietIds} />
|
||||
|
||||
{hasHousehold && (
|
||||
<CheckboxOption checked={suitableForHousehold} onChange={setSuitableForHousehold}>
|
||||
{t("planning.picker.suitableForHouseholdLabel")}
|
||||
</CheckboxOption>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RecipeTabs active={activeTab} onChange={setActiveTab} />
|
||||
|
||||
{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) =>
|
||||
setSelectedRecipe(listState.recipes.find((recipe) => recipe.id === id) ?? null)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
125
apps/web/src/features/planning/recipe-picker-dialog.scss
Normal file
125
apps/web/src/features/planning/recipe-picker-dialog.scss
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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<DateTime>(() => getWeekStart(DateTime.utc()));
|
||||
const [state, setState] = useState<PlanningState>({ 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<PlanningSlot | null>(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 (
|
||||
<div className="planning-page">
|
||||
<div className="planning-page__header">
|
||||
|
|
@ -75,7 +125,20 @@ export function PlanningPage() {
|
|||
)}
|
||||
|
||||
{state.status === "loaded" && (
|
||||
<PlanningGrid weekStart={weekStart} planning={state.planning} />
|
||||
<PlanningGrid
|
||||
weekStart={weekStart}
|
||||
planning={state.planning}
|
||||
onAddSlot={setOpenSlot}
|
||||
onRemoveItem={handleRemove}
|
||||
/>
|
||||
)}
|
||||
|
||||
{openSlot && (
|
||||
<RecipePickerDialog
|
||||
slot={openSlot}
|
||||
onClose={() => setOpenSlot(null)}
|
||||
onAdded={handleAdded}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -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({
|
|||
<MealCell
|
||||
key={weekDay}
|
||||
isToday={date.hasSame(today, "day")}
|
||||
recipes={items
|
||||
.filter((item) => 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}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
|
|
@ -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 (
|
||||
<td className={isToday ? "meal-cell today" : "meal-cell"}>
|
||||
<div className="meal-cell__content">
|
||||
{recipes.length > 0 && (
|
||||
{items.length > 0 && (
|
||||
<div className="meal-cell__recipes">
|
||||
{recipes.map((recipe) => (
|
||||
<span key={recipe.id} className="recipe-chip">
|
||||
<span className="recipe-chip__name">{recipe.name}</span>
|
||||
{items.map((item) => (
|
||||
<span key={item.id} className="recipe-chip">
|
||||
<span className="recipe-chip__name">
|
||||
{item.recipe.name} · ×{item.portions}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="recipe-chip__remove"
|
||||
title={t("planning.grid.removeRecipe")}
|
||||
onClick={() => onRemove(item)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
|
@ -309,7 +386,12 @@ function MealCell({
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="add-recipe-btn" title={t("planning.grid.addRecipeSoon")}>
|
||||
<button
|
||||
type="button"
|
||||
className="add-recipe-btn"
|
||||
title={t("planning.grid.addRecipe")}
|
||||
onClick={onAdd}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof getPlanningByDateSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof addPlanningItemSchema>;
|
||||
|
|
|
|||
|
|
@ -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<typeof recipeTabSchema>;
|
||||
|
||||
/** 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<typeof listRecipesSchema>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue