batchCooking/apps/web/src/pages/PlanningPage.tsx
Nicolas 8bdbfda3ae 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>
2026-08-19 14:44:02 +02:00

400 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
DateTime,
addWeeks,
buildCalendarMonth,
formatDateOnly,
getWeekStart,
toDateOnly,
} from "@batch-cooking/date-tools";
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. */
type PlanningState =
| { status: "loading" }
| { status: "loaded"; planning: PlanningView | null }
| { status: "error" };
/** Meals that close out a "moment of the day" group (Matin/Midi/Après-midi/Soir) — see `.band-end` in planning-page.scss for the resulting border treatment. */
const BAND_END_MEALS: ReadonlySet<Meal> = new Set(["collation", "dejeuner", "gouter"]);
/**
* Landing page for an authenticated visitor — the household's planning for
* a selectable week, laid out as a grid (days × meals). Behind
* {@link RequireAuth} (via `AppLayout`), so this only renders once a
* session is confirmed.
*
* `null` from the API is a normal, common state (no planning for that week
* yet) — unlike the previous single-day table view this replaces, it isn't
* rendered as a separate "empty" message: the grid itself, with every cell
* showing just its "+" button, already communicates that. The "+" itself
* isn't wired to anything yet (no recipe catalog to search — see the
* planning page's plan/PR description) — a future task.
*/
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;
setState({ status: "loading" });
apiClient
.getPlanningForWeek(formatDateOnly(weekStart))
.then((planning) => {
if (!cancelled) setState({ status: "loaded", planning });
})
.catch(() => {
if (!cancelled) setState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [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">
<h1>{t("planning.title")}</h1>
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
</div>
{state.status === "loading" && (
<p className="planning-page__status">{t("planning.loading")}</p>
)}
{state.status === "error" && (
<p className="planning-page__status planning-page__status--error">
{t("common.loadError")}
</p>
)}
{state.status === "loaded" && (
<PlanningGrid
weekStart={weekStart}
planning={state.planning}
onAddSlot={setOpenSlot}
onRemoveItem={handleRemove}
/>
)}
{openSlot && (
<RecipePickerDialog
slot={openSlot}
onClose={() => setOpenSlot(null)}
onAdded={handleAdded}
/>
)}
</div>
);
}
/** "17 au 23 août 2026" — collapses the month/year to just the end date when both ends of the week share it, spells it out on both ends otherwise (e.g. a week straddling two months). */
function formatWeekRange(weekStart: DateTime): string {
const weekEnd = weekStart.plus({ days: 6 });
const sameMonth = weekStart.hasSame(weekEnd, "month");
const startLabel = weekStart.toLocaleString(
sameMonth ? { day: "numeric" } : { day: "numeric", month: "long" },
{ locale: "fr" },
);
const endLabel = weekEnd.toLocaleString(
{ day: "numeric", month: "long", year: "numeric" },
{ locale: "fr" },
);
return `${startLabel} au ${endLabel}`;
}
/** Arrows + clickable label opening {@link CalendarPopover} — the week-selection UI at the top of the page. */
function WeekNavigator({
weekStart,
onChangeWeek,
}: {
weekStart: DateTime;
onChangeWeek: (weekStart: DateTime) => void;
}) {
const { t } = useTranslation();
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const isThisWeek = weekStart.hasSame(getWeekStart(DateTime.utc()), "day");
return (
<div className="week-nav">
<button
type="button"
className="week-nav__arrow"
title={t("planning.weekNav.prevWeek")}
onClick={() => onChangeWeek(addWeeks(weekStart, -1))}
>
</button>
<button
type="button"
className="week-nav__label"
onClick={() => setIsCalendarOpen((open) => !open)}
>
📅 {t("planning.weekNav.label", { range: formatWeekRange(weekStart) })}
{isThisWeek && <span className="today-badge">{t("planning.weekNav.thisWeek")}</span>}
</button>
<button
type="button"
className="week-nav__arrow"
title={t("planning.weekNav.nextWeek")}
onClick={() => onChangeWeek(addWeeks(weekStart, 1))}
>
</button>
{isCalendarOpen && (
<CalendarPopover
selectedWeekStart={weekStart}
onSelectDay={(day) => {
onChangeWeek(getWeekStart(day));
setIsCalendarOpen(false);
}}
onClose={() => setIsCalendarOpen(false)}
/>
)}
</div>
);
}
/** Month calendar letting the visitor jump to any week at once — selecting a day selects its whole (Monday-first) week. Closes itself on an outside click. */
function CalendarPopover({
selectedWeekStart,
onSelectDay,
onClose,
}: {
selectedWeekStart: DateTime;
onSelectDay: (day: DateTime) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
// Its own state: browsing to a different month to pick a week there
// shouldn't jump back every render — only re-anchors when the popover is
// first opened (`selectedWeekStart` at that point), not while it's open.
const [visibleMonth, setVisibleMonth] = useState(() => selectedWeekStart.startOf("month"));
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const today = toDateOnly(DateTime.utc());
const selectedWeekEnd = selectedWeekStart.plus({ days: 6 });
const weeks = buildCalendarMonth(visibleMonth);
return (
<div className="calendar-popover" ref={popoverRef}>
<div className="calendar-popover__header">
<button
type="button"
title={t("planning.calendar.prevMonth")}
onClick={() => setVisibleMonth((month) => month.minus({ months: 1 }))}
>
</button>
<span>
{visibleMonth.toLocaleString({ month: "long", year: "numeric" }, { locale: "fr" })}
</span>
<button
type="button"
title={t("planning.calendar.nextMonth")}
onClick={() => setVisibleMonth((month) => month.plus({ months: 1 }))}
>
</button>
</div>
<div className="calendar-grid">
{WEEK_DAYS.map((weekDay) => (
<span key={weekDay} className="calendar-grid__weekday">
{t(`planning.days.${weekDay}`).charAt(0)}
</span>
))}
{weeks.flat().map((day) => {
const classNames = ["calendar-grid__day"];
if (!day.hasSame(visibleMonth, "month")) classNames.push("calendar-grid__day--muted");
if (day >= selectedWeekStart && day <= selectedWeekEnd) {
classNames.push("calendar-grid__day--in-selected-week");
}
if (day.hasSame(today, "day")) classNames.push("calendar-grid__day--today");
return (
<button
key={day.toISO()}
type="button"
className={classNames.join(" ")}
onClick={() => onSelectDay(day)}
>
{day.day}
</button>
);
})}
</div>
</div>
);
}
/** The week grid itself — 7 day columns × 5 meal rows. */
function PlanningGrid({
weekStart,
planning,
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 }) }));
const items = planning?.items ?? [];
return (
<div className="planning-grid-wrapper">
<table className="planning-grid">
<thead>
<tr>
<th />
{days.map(({ weekDay, date }) => (
<th key={weekDay} className={date.hasSame(today, "day") ? "today" : undefined}>
<span className="day-name">{t(`planning.days.${weekDay}`)}</span>
<span className="day-date">{date.day}</span>
</th>
))}
</tr>
</thead>
<tbody>
{MEALS.map((meal) => (
<tr key={meal} className={BAND_END_MEALS.has(meal) ? "band-end" : undefined}>
<th>{t(`planning.meals.${meal}`)}</th>
{days.map(({ weekDay, date }) => (
<MealCell
key={weekDay}
isToday={date.hasSame(today, "day")}
items={items.filter((item) => item.weekDay === weekDay && item.meal === meal)}
onAdd={() => onAddSlot({ date: formatDateOnly(date), weekDay, meal })}
onRemove={onRemoveItem}
/>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
/** 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,
items,
onAdd,
onRemove,
}: {
isToday: boolean;
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">
{items.length > 0 && (
<div className="meal-cell__recipes">
{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>
</span>
))}
</div>
)}
<button
type="button"
className="add-recipe-btn"
title={t("planning.grid.addRecipe")}
onClick={onAdd}
>
+
</button>
</div>
</td>
);
}