feat(planning): dialog de sélection de recette, création de planning, assignation avec portions
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 <noreply@anthropic.com>
This commit is contained in:
parent
f1fefc1f38
commit
8bdbfda3ae
18 changed files with 1029 additions and 36 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")
|
weekDay String @map("week_day")
|
||||||
meal String
|
meal String
|
||||||
recipeId Int @map("recipe_id")
|
recipeId Int @map("recipe_id")
|
||||||
|
portions Int
|
||||||
|
|
||||||
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
|
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
|
||||||
recipe Recipe @relation(fields: [recipeId], references: [id])
|
recipe Recipe @relation(fields: [recipeId], references: [id])
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { parseDateOnly } from "@batch-cooking/date-tools";
|
import { parseDateOnly } from "@batch-cooking/date-tools";
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-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 { Router } from "express";
|
||||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
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. */
|
/** Router mounted at `/planning` in app.ts. */
|
||||||
export const planningRouter = Router();
|
export const planningRouter = Router();
|
||||||
|
|
@ -34,3 +34,48 @@ planningRouter.get(
|
||||||
res.status(200).json(planning);
|
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 DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
|
||||||
import type { PlanningView } from "@batch-cooking/shared";
|
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 { 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
|
* 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
|
* Returns `null` for two distinct, both entirely normal states — a
|
||||||
* `houseId` of `null` (the profile has no household yet — households are no
|
* `houseId` of `null` (the profile has no household yet — households are no
|
||||||
* longer created automatically at signup, see `auth.service.ts`) and "no
|
* longer created automatically at signup, see `auth.service.ts`) and "no
|
||||||
* planning row covers this date" (the expected case until planning
|
* planning row covers this date yet" (e.g. a week nobody has added a recipe
|
||||||
* creation is built) — neither is an error, so both collapse to the same
|
* to via {@link addPlanningItem}) — neither is an error, so both collapse
|
||||||
* "nothing to show yet" result rather than throwing.
|
* to the same "nothing to show yet" result rather than throwing.
|
||||||
*/
|
*/
|
||||||
export async function getPlanningForDate(
|
export async function getPlanningForDate(
|
||||||
houseId: number | null,
|
houseId: number | null,
|
||||||
|
|
@ -59,7 +66,102 @@ export async function getPlanningForDate(
|
||||||
id: item.id,
|
id: item.id,
|
||||||
weekDay: item.weekDay,
|
weekDay: item.weekDay,
|
||||||
meal: item.meal,
|
meal: item.meal,
|
||||||
|
portions: item.portions,
|
||||||
recipe: item.recipe,
|
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) => {
|
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||||
const input = listRecipesSchema.parse(req.query);
|
const input = listRecipesSchema.parse(req.query);
|
||||||
const { id: viewerId, houseId } = res.locals.userProfile;
|
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,
|
* 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
|
* "toutes" tab — every recipe a viewer can see falls under exactly one of
|
||||||
* `perso`/`foyer`/`publique` (its own visibility); `favoris` is an
|
* `perso`/`foyer`/`publique` (its own visibility); `favoris` is an
|
||||||
* orthogonal, cross-cutting filter on top (and re-applies
|
* orthogonal, cross-cutting filter on top (and re-applies
|
||||||
|
|
@ -153,12 +215,29 @@ export async function listRecipes(
|
||||||
viewerId: number,
|
viewerId: number,
|
||||||
viewerHouseId: number | null,
|
viewerHouseId: number | null,
|
||||||
tab: RecipeTab,
|
tab: RecipeTab,
|
||||||
search?: string,
|
filters: ListRecipesFilters = {},
|
||||||
): Promise<RecipeSummaryView[]> {
|
): Promise<RecipeSummaryView[]> {
|
||||||
|
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
|
||||||
const conditions: Prisma.RecipeWhereInput[] = [];
|
const conditions: Prisma.RecipeWhereInput[] = [];
|
||||||
if (search) {
|
if (search) {
|
||||||
conditions.push({ name: { contains: search, mode: "insensitive" } });
|
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) {
|
switch (tab) {
|
||||||
case "favoris":
|
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 } });
|
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). */
|
/** 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> {
|
async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWithDetails> {
|
||||||
const recipe = await prisma.recipe.findUnique({
|
const recipe = await prisma.recipe.findUnique({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import {
|
import {
|
||||||
|
type AddPlanningItemInput,
|
||||||
type AllergyView,
|
type AllergyView,
|
||||||
type ApiErrorResponse,
|
type ApiErrorResponse,
|
||||||
type CreateRecipeInput,
|
type CreateRecipeInput,
|
||||||
|
|
@ -7,6 +8,7 @@ import {
|
||||||
type HouseView,
|
type HouseView,
|
||||||
type IngredientView,
|
type IngredientView,
|
||||||
type LoginInput,
|
type LoginInput,
|
||||||
|
type PlanningItemView,
|
||||||
type PlanningView,
|
type PlanningView,
|
||||||
type PreferencesView,
|
type PreferencesView,
|
||||||
type RecipeSummaryView,
|
type RecipeSummaryView,
|
||||||
|
|
@ -128,6 +130,22 @@ export class ApiClient {
|
||||||
return this.request(`/planning?date=${date}`);
|
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. */
|
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
|
||||||
public getDiets(): Promise<DietView[]> {
|
public getDiets(): Promise<DietView[]> {
|
||||||
return this.request("/reference/diets");
|
return this.request("/reference/diets");
|
||||||
|
|
@ -143,10 +161,27 @@ export class ApiClient {
|
||||||
return this.request("/reference/ingredients");
|
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 });
|
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()}`);
|
return this.request(`/recipes?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
80
apps/web/src/components/ui/Dialog.tsx
Normal file
80
apps/web/src/components/ui/Dialog.tsx
Normal file
|
|
@ -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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
className="dialog-overlay"
|
||||||
|
// Closing on the overlay itself (not on a bubbled click from the
|
||||||
|
// panel) — same "outside click" idea as CalendarPopover, expressed
|
||||||
|
// via where the click *landed* instead of a document-level listener
|
||||||
|
// + ref containment check, since the overlay already exactly frames
|
||||||
|
// "outside the panel".
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={["dialog-panel", className].filter(Boolean).join(" ")}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
ref={panelRef}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<div className="dialog-panel__header">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dialog-panel__close"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Fermer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="dialog-panel__body">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 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);
|
||||||
|
}
|
||||||
289
apps/web/src/features/planning/RecipePickerDialog.tsx
Normal file
289
apps/web/src/features/planning/RecipePickerDialog.tsx
Normal file
|
|
@ -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<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
|
||||||
|
isOpen
|
||||||
|
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)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{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 isOpen 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"
|
"diner": "Dîner"
|
||||||
},
|
},
|
||||||
"grid": {
|
"grid": {
|
||||||
"addRecipeSoon": "Recherche de recettes à venir",
|
"addRecipe": "Ajouter une recette",
|
||||||
"removeRecipe": "Retirer cette 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": {
|
"recipes": {
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,17 @@ import {
|
||||||
getWeekStart,
|
getWeekStart,
|
||||||
toDateOnly,
|
toDateOnly,
|
||||||
} from "@batch-cooking/date-tools";
|
} 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 { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { apiClient } from "../api/client";
|
import { apiClient } from "../api/client";
|
||||||
|
import { type PlanningSlot, RecipePickerDialog } from "../features/planning/RecipePickerDialog";
|
||||||
import "./planning-page.scss";
|
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. */
|
/** 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 { t } = useTranslation();
|
||||||
const [weekStart, setWeekStart] = useState<DateTime>(() => getWeekStart(DateTime.utc()));
|
const [weekStart, setWeekStart] = useState<DateTime>(() => getWeekStart(DateTime.utc()));
|
||||||
const [state, setState] = useState<PlanningState>({ status: "loading" });
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -57,6 +70,43 @@ export function PlanningPage() {
|
||||||
};
|
};
|
||||||
}, [weekStart]);
|
}, [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 (
|
return (
|
||||||
<div className="planning-page">
|
<div className="planning-page">
|
||||||
<div className="planning-page__header">
|
<div className="planning-page__header">
|
||||||
|
|
@ -75,7 +125,20 @@ export function PlanningPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state.status === "loaded" && (
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -239,7 +302,14 @@ function CalendarPopover({
|
||||||
function PlanningGrid({
|
function PlanningGrid({
|
||||||
weekStart,
|
weekStart,
|
||||||
planning,
|
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 { t } = useTranslation();
|
||||||
const today = toDateOnly(DateTime.utc());
|
const today = toDateOnly(DateTime.utc());
|
||||||
const days = WEEK_DAYS.map((weekDay, i) => ({ weekDay, date: weekStart.plus({ days: i }) }));
|
const days = WEEK_DAYS.map((weekDay, i) => ({ weekDay, date: weekStart.plus({ days: i }) }));
|
||||||
|
|
@ -267,9 +337,9 @@ function PlanningGrid({
|
||||||
<MealCell
|
<MealCell
|
||||||
key={weekDay}
|
key={weekDay}
|
||||||
isToday={date.hasSame(today, "day")}
|
isToday={date.hasSame(today, "day")}
|
||||||
recipes={items
|
items={items.filter((item) => item.weekDay === weekDay && item.meal === meal)}
|
||||||
.filter((item) => item.weekDay === weekDay && item.meal === meal)
|
onAdd={() => onAddSlot({ date: formatDateOnly(date), weekDay, meal })}
|
||||||
.map((item) => ({ id: item.id, name: item.recipe.name }))}
|
onRemove={onRemoveItem}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</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({
|
function MealCell({
|
||||||
isToday,
|
isToday,
|
||||||
recipes,
|
items,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
isToday: boolean;
|
isToday: boolean;
|
||||||
recipes: { id: number; name: string }[];
|
items: PlanningItemView[];
|
||||||
|
onAdd: () => void;
|
||||||
|
onRemove: (item: PlanningItemView) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<td className={isToday ? "meal-cell today" : "meal-cell"}>
|
<td className={isToday ? "meal-cell today" : "meal-cell"}>
|
||||||
<div className="meal-cell__content">
|
<div className="meal-cell__content">
|
||||||
{recipes.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div className="meal-cell__recipes">
|
<div className="meal-cell__recipes">
|
||||||
{recipes.map((recipe) => (
|
{items.map((item) => (
|
||||||
<span key={recipe.id} className="recipe-chip">
|
<span key={item.id} className="recipe-chip">
|
||||||
<span className="recipe-chip__name">{recipe.name}</span>
|
<span className="recipe-chip__name">
|
||||||
|
{item.recipe.name} · ×{item.portions}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="recipe-chip__remove"
|
className="recipe-chip__remove"
|
||||||
title={t("planning.grid.removeRecipe")}
|
title={t("planning.grid.removeRecipe")}
|
||||||
|
onClick={() => onRemove(item)}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -309,7 +386,12 @@ function MealCell({
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export function RecipesPage() {
|
||||||
setListState({ status: "loading" });
|
setListState({ status: "loading" });
|
||||||
|
|
||||||
apiClient
|
apiClient
|
||||||
.listRecipes(activeTab, debouncedSearch.trim() || undefined)
|
.listRecipes(activeTab, { search: debouncedSearch.trim() || undefined })
|
||||||
.then((recipes) => {
|
.then((recipes) => {
|
||||||
if (!cancelled) setListState({ status: "loaded", recipes });
|
if (!cancelled) setListState({ status: "loaded", recipes });
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@ export enum ErrorCode {
|
||||||
RECIPE_NOT_FOUND = 4045,
|
RECIPE_NOT_FOUND = 4045,
|
||||||
/** A recipe payload's `ingredientId` doesn't match any reference `Ingredient` row. */
|
/** A recipe payload's `ingredientId` doesn't match any reference `Ingredient` row. */
|
||||||
INGREDIENT_NOT_FOUND = 4046,
|
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. */
|
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
||||||
INTERNAL_ERROR = 5000,
|
INTERNAL_ERROR = 5000,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { MEALS, WEEK_DAYS } from "../types/planning.js";
|
||||||
|
|
||||||
// See schemas/auth.ts for the shared client/server validation rationale.
|
// 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. */
|
/** Inferred TS type for {@link getPlanningByDateSchema}'s validated output. */
|
||||||
export type GetPlanningByDateInput = z.infer<typeof getPlanningByDateSchema>;
|
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. */
|
/** Inferred TS type for {@link recipeTabSchema}'s validated output. */
|
||||||
export type RecipeTab = z.infer<typeof recipeTabSchema>;
|
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({
|
export const listRecipesSchema = z.object({
|
||||||
tab: recipeTabSchema,
|
tab: recipeTabSchema,
|
||||||
search: z.string().trim().min(1).optional(),
|
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. */
|
/** Inferred TS type for {@link listRecipesSchema}'s validated output. */
|
||||||
export type ListRecipesInput = z.infer<typeof listRecipesSchema>;
|
export type ListRecipesInput = z.infer<typeof listRecipesSchema>;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
/**
|
/**
|
||||||
* The 7 values `PlanningItemView.weekDay` is expected to take — lowercase,
|
* The 7 values `PlanningItemView.weekDay` is expected to take — lowercase,
|
||||||
* unaccented French day names. Not enforced by the database (`week_day` is
|
* 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
|
* a plain `String` column, see schema.prisma), but enforced at the write
|
||||||
* (there isn't one), but this is the contract the planning grid
|
* boundary by `schemas/planning.ts`'s `addPlanningItemSchema` (`POST
|
||||||
* (`apps/web`'s `PlanningPage`) reads against, and the one a future
|
* /planning/items`) — this is the contract the planning grid (`apps/web`'s
|
||||||
* "add a recipe to a slot" endpoint should write.
|
* `PlanningPage`) reads against, and the one that endpoint writes.
|
||||||
*/
|
*/
|
||||||
export const WEEK_DAYS = [
|
export const WEEK_DAYS = [
|
||||||
"lundi",
|
"lundi",
|
||||||
|
|
@ -20,8 +20,7 @@ export type WeekDay = (typeof WEEK_DAYS)[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The 5 values `PlanningItemView.meal` is expected to take, in day order —
|
* The 5 values `PlanningItemView.meal` is expected to take, in day order —
|
||||||
* same "documented but not enforced yet" status as {@link WEEK_DAYS}, same
|
* same enforcement story as {@link WEEK_DAYS}.
|
||||||
* reason.
|
|
||||||
*/
|
*/
|
||||||
export const MEALS = ["petit-dejeuner", "collation", "dejeuner", "gouter", "diner"] as const;
|
export const MEALS = ["petit-dejeuner", "collation", "dejeuner", "gouter", "diner"] as const;
|
||||||
/** Inferred TS type for one {@link MEALS} member. */
|
/** Inferred TS type for one {@link MEALS} member. */
|
||||||
|
|
@ -38,6 +37,8 @@ export interface PlanningItemView {
|
||||||
weekDay: string;
|
weekDay: string;
|
||||||
/** Which meal of the day this item is for — see {@link Meal} (free-form for now, same reason). */
|
/** Which meal of the day this item is for — see {@link Meal} (free-form for now, same reason). */
|
||||||
meal: string;
|
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: {
|
recipe: {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue