feat(planning): glisser-déposer un reste sur un autre créneau #51

Merged
kyuno merged 1 commit from feat/leftovers-drag-drop into main 2026-09-06 01:06:11 +02:00
13 changed files with 615 additions and 80 deletions

View file

@ -0,0 +1,24 @@
Feature: Serving leftovers from the planning basket
As a signed-in user
I want to drag a week's leftovers onto another slot
So that I don't cook a fresh batch for a meal an existing one already covers
Background:
Given I am signed in as "Alice" "Martin"
And my household id is 1
And the planning has a batch with leftovers to spare
Scenario: Drag a leftover onto another slot, confirm the portions, and see the "restes" chip
When I visit "/"
And I open the leftovers basket
Then the leftovers basket lists "Lasagnes" with 2 portions
When I drag the "Lasagnes" leftover onto Wednesday lunch
And I confirm serving 2 leftover portions
Then Wednesday lunch shows a leftovers chip for "Lasagnes"
And the leftovers basket count is gone
Scenario: Throw a leftover away from the basket
When I visit "/"
And I open the leftovers basket
And I throw away the "Lasagnes" leftover
Then the leftovers basket shows nothing left

View file

@ -0,0 +1,132 @@
import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
// Mocks the API via cy.intercept — this job has no live backend (see
// .github/workflows/ci.yml); apps/api's Mocha suite covers the real
// leftover endpoints. Drives @dnd-kit's MouseSensor with raw mouse
// events (mousedown on the handle → two mousemoves → mouseup on the
// target), the reliable way to script one of its drags in Cypress.
/** One fresh batch: 12 prepared for 10 requested -> a pool of 2, no leftovers item pointing at it yet. */
const SOURCE_ITEM = {
id: 1,
weekDay: "lundi",
meal: "diner",
portions: 12,
portionsRequested: 10,
discardedPortions: 0,
sourceItem: null,
recipe: { id: 1, name: "Lasagnes" },
};
Given("the planning has a batch with leftovers to spare", () => {
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
cy.intercept("GET", /\/planning\?/, {
statusCode: 200,
body: {
id: 1,
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
items: [SOURCE_ITEM],
},
}).as("getPlanning");
});
When("I open the leftovers basket", () => {
cy.get(".leftovers-basket__toggle").click();
});
Then(
"the leftovers basket lists {string} with {int} portions",
(recipe: string, portions: number) => {
cy.contains(".leftovers-basket__line", recipe).should("contain.text", `${portions} en restes`);
},
);
/** dnd-kit's MouseSensor listens on the drag element for `mousedown`, then on the document for `mousemove`/`mouseup`; a first move past the 5px activation distance, then a move onto the target, then release. */
function dragLeftoverOnto(targetSelectorChain: () => Cypress.Chainable<JQuery<HTMLElement>>) {
cy.get(".leftovers-basket__drag-handle")
.first()
.then(($handle) => {
const h = $handle[0].getBoundingClientRect();
const start = {
clientX: Math.round(h.left + h.width / 2),
clientY: Math.round(h.top + h.height / 2),
};
targetSelectorChain().then(($target) => {
const t = $target[0].getBoundingClientRect();
const end = {
clientX: Math.round(t.left + t.width / 2),
clientY: Math.round(t.top + t.height / 2),
};
cy.wrap($handle).trigger("mousedown", { button: 0, ...start });
cy.get("body")
.trigger("mousemove", {
button: 0,
clientX: start.clientX + 12,
clientY: start.clientY + 12,
})
.trigger("mousemove", { button: 0, ...end })
.trigger("mouseup", { button: 0, ...end });
});
});
}
/** The `<td>` for (Wednesday, lunch): MEALS order puts `dejeuner` at row index 2, WEEK_DAYS puts `mercredi` at td index 2. */
function wednesdayLunchCell() {
return cy.get(".planning-grid tbody tr").eq(2).find("td").eq(2);
}
When("I drag the {string} leftover onto Wednesday lunch", (_recipe: string) => {
cy.intercept("POST", "**/planning/items/leftover", (req) => {
req.reply({
statusCode: 201,
body: {
id: 99,
weekDay: "mercredi",
meal: "dejeuner",
portions: req.body.portions,
portionsRequested: req.body.portions,
discardedPortions: 0,
sourceItem: { id: req.body.sourceItemId },
recipe: { id: 1, name: "Lasagnes" },
},
});
}).as("assignLeftover");
dragLeftoverOnto(wednesdayLunchCell);
});
When("I confirm serving {int} leftover portions", (portions: number) => {
cy.get("#leftover-assign-portions").clear().type(String(portions));
cy.contains(".recipe-picker-confirm__confirm", "Ajouter au planning").click();
cy.wait("@assignLeftover").its("request.body").should("deep.include", {
sourceItemId: 1,
weekDay: "mercredi",
meal: "dejeuner",
portions,
});
});
Then("Wednesday lunch shows a leftovers chip for {string}", (recipe: string) => {
wednesdayLunchCell()
.find(".recipe-chip--leftover")
.should("contain.text", recipe)
.and("contain.text", "♻️");
});
Then("the leftovers basket count is gone", () => {
cy.get(".leftovers-basket__count").should("not.exist");
});
When("I throw away the {string} leftover", (recipe: string) => {
cy.intercept("PATCH", "**/planning/items/1/discard-leftover", {
statusCode: 200,
body: { ...SOURCE_ITEM, discardedPortions: 2 },
}).as("discard");
cy.contains(".leftovers-basket__line", recipe).find(".leftovers-basket__discard").click();
cy.wait("@discard");
});
Then("the leftovers basket shows nothing left", () => {
cy.contains(".leftovers-basket__empty", "Aucun reste cette semaine.").should("be.visible");
});

View file

@ -16,6 +16,8 @@
"dependencies": { "dependencies": {
"@batch-cooking/date-tools": "workspace:*", "@batch-cooking/date-tools": "workspace:*",
"@batch-cooking/shared": "workspace:*", "@batch-cooking/shared": "workspace:*",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"i18next": "^26.3.6", "i18next": "^26.3.6",
"lucide-react": "^1.32.0", "lucide-react": "^1.32.0",
"react": "^18.3.1", "react": "^18.3.1",

View file

@ -2,6 +2,7 @@ import {
type AddPlanningItemInput, type AddPlanningItemInput,
type AllergyView, type AllergyView,
type ApiErrorResponse, type ApiErrorResponse,
type AssignLeftoverInput,
type BrowsableSourceItemView, type BrowsableSourceItemView,
type CreateRecipeInput, type CreateRecipeInput,
type DietView, type DietView,
@ -171,6 +172,14 @@ export class ApiClient {
return this._request(`/planning/items/${id}/discard-leftover`, { method: "PATCH" }); return this._request(`/planning/items/${id}/discard-leftover`, { method: "PATCH" });
} }
/** Serves `portions` of a source item's leftover pool on a slot, creating a "leftovers" planning item (same recipe as the source). Rejects with `LEFTOVER_QUANTITY_EXCEEDED` (over the pool) or `PLANNING_ITEM_NOT_FOUND`. */
public assignLeftover(input: AssignLeftoverInput): Promise<PlanningItemView> {
return this._request("/planning/items/leftover", {
method: "POST",
body: JSON.stringify(input),
});
}
/** /**
* Fetches the current user's household's shopping list for the week * Fetches the current user's household's shopping list for the week
* covering `date` (`YYYY-MM-DD`, e.g. from `date-tools`'s * covering `date` (`YYYY-MM-DD`, e.g. from `date-tools`'s

View file

@ -0,0 +1,81 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Dialog } from "../../components/ui/Dialog";
/**
* The little "how many portions?" step shown after a leftover card is
* dropped onto a planning slot — same shape/style as `RecipePickerDialog`'s
* own portions step (`.recipe-picker-confirm`). Defaults to the whole
* available pool, capped at it; `onConfirm` does the actual
* `POST /planning/items/leftover` and reports back a rejection message to
* show inline.
*/
export function LeftoverAssignDialog({
recipeName,
available,
onCancel,
onConfirm,
}: {
recipeName: string;
available: number;
onCancel: () => void;
/** Resolves to an error label to display, or `null` on success (the caller closes the dialog itself). */
onConfirm: (portions: number) => Promise<string | null>;
}) {
const { t } = useTranslation();
const [portions, setPortions] = useState(String(available));
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const parsed = Number(portions);
const isValid = Number.isInteger(parsed) && parsed >= 1 && parsed <= available;
async function submit() {
if (!isValid) return;
setIsSubmitting(true);
setError(null);
try {
const message = await onConfirm(parsed);
if (message !== null) {
setError(message);
setIsSubmitting(false);
}
} catch {
setError(t("errors.INTERNAL_ERROR"));
setIsSubmitting(false);
}
}
return (
<Dialog onClose={onCancel} title={t("planning.leftovers.assignTitle", { recipe: recipeName })}>
<div className="recipe-picker-confirm">
<label htmlFor="leftover-assign-portions">
{t("planning.leftovers.assignPortionsLabel", { max: available })}
</label>
<input
id="leftover-assign-portions"
type="number"
min="1"
max={available}
step="1"
value={portions}
onChange={(e) => setPortions(e.target.value)}
/>
{error && <p className="field-error">{error}</p>}
<div className="recipe-picker-confirm__actions">
<button type="button" onClick={onCancel} disabled={isSubmitting}>
{t("planning.leftovers.assignCancel")}
</button>
<button
type="button"
className="recipe-picker-confirm__confirm"
onClick={() => void submit()}
disabled={isSubmitting || !isValid}
>
{isSubmitting ? t("planning.picker.adding") : t("planning.leftovers.assignConfirm")}
</button>
</div>
</div>
</Dialog>
);
}

View file

@ -1,8 +1,9 @@
import type { PlanningItemView, PlanningView } from "@batch-cooking/shared"; import type { PlanningItemView, PlanningView } from "@batch-cooking/shared";
import { useDraggable } from "@dnd-kit/core";
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 { collectLeftoverLines } from "./leftovers-basket"; import { collectLeftoverLines, dragIdForLeftover } from "./leftovers-basket";
import "./leftovers-basket.scss"; import "./leftovers-basket.scss";
/** /**
@ -11,12 +12,13 @@ import "./leftovers-basket.scss";
* one line per fresh planning item that cooked more than it needed, with * one line per fresh planning item that cooked more than it needed, with
* where it came from, how many portions are left, and a "jeter" button. * where it came from, how many portions are left, and a "jeter" button.
* *
* Read-only for now: dragging a leftover onto another slot arrives in a * Each line is also draggable (`@dnd-kit`, see {@link LeftoverLineCard})
* later iteration. Same conditional-mount + outside-click-to-close pattern * onto a planning cell to serve part of its pool there — `PlanningPage`
* as `WeekNavigator`'s `CalendarPopover`. The list is derived on every * owns the `DndContext` and the drop handling. Same conditional-mount +
* render from `planning` (see {@link collectLeftoverLines}) rather than * outside-click-to-close pattern as `WeekNavigator`'s `CalendarPopover`.
* held in its own state, so a discard elsewhere on the page is reflected * The list is derived on every render from `planning` (see
* here for free. * {@link collectLeftoverLines}) rather than held in its own state, so a
* discard or assignment elsewhere on the page is reflected here for free.
*/ */
export function LeftoversBasket({ export function LeftoversBasket({
planning, planning,
@ -79,28 +81,13 @@ export function LeftoversBasket({
) : ( ) : (
<ul className="leftovers-basket__list"> <ul className="leftovers-basket__list">
{lines.map(({ item, available }) => ( {lines.map(({ item, available }) => (
<li key={item.id} className="leftovers-basket__line"> <LeftoverLineCard
<div className="leftovers-basket__meta"> key={item.id}
<span className="leftovers-basket__recipe">{item.recipe.name}</span> item={item}
<span className="leftovers-basket__origin"> available={available}
{t("planning.leftovers.origin", { isDiscarding={discardingId === item.id}
day: t(`common.days.${item.weekDay}`), onDiscard={() => void discard(item)}
meal: t(`planning.meals.${item.meal}`), />
})}
</span>
</div>
<span className="leftovers-basket__qty">
{t("planning.leftovers.available", { count: available })}
</span>
<button
type="button"
className="leftovers-basket__discard"
onClick={() => void discard(item)}
disabled={discardingId === item.id}
>
{t("planning.leftovers.discard")}
</button>
</li>
))} ))}
</ul> </ul>
)} )}
@ -109,3 +96,66 @@ export function LeftoversBasket({
</div> </div>
); );
} }
/**
* One basket line, draggable (`@dnd-kit` `useDraggable`) onto a planning
* cell to serve part of its pool there. The drag `data` carries what
* `PlanningPage`'s `onDragEnd` needs to open the "how many portions?"
* step; the sensors' activation distance (see `PlanningPage`) keeps a
* plain click on "Jeter" from starting a drag.
*/
function LeftoverLineCard({
item,
available,
isDiscarding,
onDiscard,
}: {
item: PlanningItemView;
available: number;
isDiscarding: boolean;
onDiscard: () => void;
}) {
const { t } = useTranslation();
// The draggable node is the small grip button, not the whole `<li>`: a
// pointer-based collision test then resolves cleanly to the single cell
// under the cursor (a wide `<li>` box would straddle several cells).
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: dragIdForLeftover(item.id),
data: { sourceItemId: item.id, recipeName: item.recipe.name, available },
});
return (
<li className={isDragging ? "leftovers-basket__line is-dragging" : "leftovers-basket__line"}>
<button
type="button"
ref={setNodeRef}
className="leftovers-basket__drag-handle"
aria-label={t("planning.leftovers.dragHandle", { recipe: item.recipe.name })}
{...attributes}
{...listeners}
>
⠿
</button>
<div className="leftovers-basket__meta">
<span className="leftovers-basket__recipe">{item.recipe.name}</span>
<span className="leftovers-basket__origin">
{t("planning.leftovers.origin", {
day: t(`common.days.${item.weekDay}`),
meal: t(`planning.meals.${item.meal}`),
})}
</span>
</div>
<span className="leftovers-basket__qty">
{t("planning.leftovers.available", { count: available })}
</span>
<button
type="button"
className="leftovers-basket__discard"
onClick={onDiscard}
disabled={isDiscarding}
>
{t("planning.leftovers.discard")}
</button>
</li>
);
}

View file

@ -69,6 +69,29 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-sm); gap: var(--space-sm);
&.is-dragging {
opacity: 0.5;
}
}
// Grip handle — the only part that starts a drag, so a click on "Jeter"
// never gets hijacked. `touch-action: none` lets @dnd-kit's PointerSensor
// own the gesture on touch devices.
&__drag-handle {
flex-shrink: 0;
padding: 0 var(--space-xs);
font-size: var(--font-size-base);
line-height: 1;
cursor: grab;
color: var(--color-text-muted);
background: none;
border: none;
touch-action: none;
&:active {
cursor: grabbing;
}
} }
&__meta { &__meta {

View file

@ -11,6 +11,18 @@ export interface LeftoverLine {
available: number; available: number;
} }
/** Payload a dragged leftover card carries (`useDraggable`'s `data`) — read by `PlanningPage`'s `onDragEnd` to pre-fill the assign step. */
export interface LeftoverDragData {
sourceItemId: number;
recipeName: string;
available: number;
}
/** `useDraggable` id for one leftover line — namespaced so it can't collide with a droppable cell's id. */
export function dragIdForLeftover(itemId: number): string {
return `leftover-${itemId}`;
}
/** /**
* The leftovers-basket rows for a week's planning: every **fresh** item * The leftovers-basket rows for a week's planning: every **fresh** item
* (`sourceItem === null`) whose leftover pool still has something in it, * (`sourceItem === null`) whose leftover pool still has something in it,

View file

@ -131,7 +131,13 @@
"empty": "Aucun reste cette semaine.", "empty": "Aucun reste cette semaine.",
"origin": "{{day}} · {{meal}}", "origin": "{{day}} · {{meal}}",
"available": "{{count}} en restes", "available": "{{count}} en restes",
"discard": "Jeter" "discard": "Jeter",
"dragHandle": "Glisser les restes de {{recipe}} vers un créneau",
"chipBadge": "Restes d'un autre créneau",
"assignTitle": "Servir des restes de {{recipe}}",
"assignPortionsLabel": "Portions à servir (max {{max}})",
"assignCancel": "Annuler",
"assignConfirm": "Ajouter au planning"
}, },
"meals": { "meals": {
"petit-dejeuner": "Petit-déjeuner", "petit-dejeuner": "Petit-déjeuner",

View file

@ -1,20 +1,57 @@
import { DateTime, formatDateOnly, getWeekStart, toDateOnly } from "@batch-cooking/date-tools"; import { DateTime, formatDateOnly, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
import { import {
ErrorCode,
MEALS, MEALS,
type Meal, type Meal,
type PlanningItemView, type PlanningItemView,
type PlanningView, type PlanningView,
WEEK_DAYS, WEEK_DAYS,
type WeekDay,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import {
type CollisionDetection,
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
MouseSensor,
pointerWithin,
TouchSensor,
useDroppable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { apiClient } from "../../api/client"; import { ApiError, apiClient } from "../../api/client";
import { LeftoverAssignDialog } from "../../features/planning/LeftoverAssignDialog";
import { LeftoversBasket } from "../../features/planning/LeftoversBasket"; import { LeftoversBasket } from "../../features/planning/LeftoversBasket";
import type { LeftoverDragData } from "../../features/planning/leftovers-basket";
import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog"; import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog";
import { WeekNavigator } from "../../features/planning/WeekNavigator"; import { WeekNavigator } from "../../features/planning/WeekNavigator";
import { errorMessageService } from "../../services/error-message.service";
import "./planning-page.scss"; import "./planning-page.scss";
/** Drop-target payload each planning cell registers (`useDroppable`'s `data`) — the slot a dragged leftover lands on. */
interface CellDropData {
date: string;
weekDay: WeekDay;
meal: Meal;
}
/**
* Drop onto whichever cell the pointer is actually over; fall back to the
* nearest cell centre only when the pointer isn't inside any (e.g. the
* keyboard sensor, which has no pointer). Cells are packed edge to edge, so
* a rect-overlap strategy would pick a neighbour whenever the small drag
* handle's translated box crossed a border.
*/
const dropOnCellUnderPointer: CollisionDetection = (args) => {
const underPointer = pointerWithin(args);
return underPointer.length > 0 ? underPointer : closestCenter(args);
};
/** 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. */
type PlanningState = type PlanningState =
| { status: "loading" } | { status: "loading" }
@ -48,6 +85,22 @@ export function PlanningPage() {
// state for free on every open, same convention as `WeekNavigator`'s own // state for free on every open, same convention as `WeekNavigator`'s own
// `CalendarPopover` (features/planning/WeekNavigator.tsx). // `CalendarPopover` (features/planning/WeekNavigator.tsx).
const [openSlot, setOpenSlot] = useState<PlanningSlot | null>(null); const [openSlot, setOpenSlot] = useState<PlanningSlot | null>(null);
// A leftover card has been dropped on a slot and we're asking how many of
// its pool to serve there — `null` means no such step is open.
const [pendingAssign, setPendingAssign] = useState<(LeftoverDragData & CellDropData) | null>(
null,
);
// Mouse + touch rather than the unified PointerSensor: same coverage, but
// driven by plain mouse/touch events, which Cypress can script reliably
// (see `planning-leftovers.ts`). A small activation distance so a plain
// click on a basket line's "Jeter" button doesn't start a drag; the
// keyboard sensor keeps the whole flow reachable without a pointer.
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }),
useSensor(KeyboardSensor),
);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -99,6 +152,35 @@ export function PlanningPage() {
patchPlanningItems((items) => items.map((i) => (i.id === updated.id ? updated : i))); patchPlanningItems((items) => items.map((i) => (i.id === updated.id ? updated : i)));
} }
/** A leftover card was dropped on a cell — open the "how many portions?" step for that (source, slot) pair. */
function handleDragEnd(event: DragEndEvent) {
const drag = event.active.data.current as LeftoverDragData | undefined;
const cell = event.over?.data.current as CellDropData | undefined;
if (!drag || !cell) return;
setPendingAssign({ ...drag, ...cell });
}
/** Confirms the assign step — `POST /planning/items/leftover`, then patches the new "leftovers" item into local state. Returns an error label to show inline, or `null` on success. */
async function confirmAssign(portions: number): Promise<string | null> {
if (!pendingAssign) return null;
try {
const item = await apiClient.assignLeftover({
date: pendingAssign.date,
weekDay: pendingAssign.weekDay,
meal: pendingAssign.meal,
sourceItemId: pendingAssign.sourceItemId,
portions,
});
patchPlanningItems((items) => [...items, item]);
setPendingAssign(null);
return null;
} catch (err) {
return errorMessageService.getLabel(
err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR,
);
}
}
/** 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. */ /** 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) { async function handleRemove(item: PlanningItemView) {
patchPlanningItems((items) => items.filter((i) => i.id !== item.id)); patchPlanningItems((items) => items.filter((i) => i.id !== item.id));
@ -114,53 +196,68 @@ export function PlanningPage() {
const hasPlannedRecipes = state.status === "loaded" && (state.planning?.items.length ?? 0) > 0; const hasPlannedRecipes = state.status === "loaded" && (state.planning?.items.length ?? 0) > 0;
return ( return (
<div className="planning-page"> <DndContext
<div className="planning-page__header"> sensors={sensors}
<h1>{t("planning.title")}</h1> collisionDetection={dropOnCellUnderPointer}
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} /> onDragEnd={handleDragEnd}
>
<div className="planning-page">
<div className="planning-page__header">
<h1>{t("planning.title")}</h1>
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
{state.status === "loaded" && (
<LeftoversBasket
planning={state.planning}
onLeftoverDiscarded={handleLeftoverDiscarded}
/>
)}
<button
type="button"
className="planning-page__cook-btn"
disabled={!hasPlannedRecipes}
onClick={() => navigate(`/cuisiner?date=${formatDateOnly(weekStart)}`)}
>
{t("planning.startCooking")}
</button>
</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" && ( {state.status === "loaded" && (
<LeftoversBasket <PlanningGrid
weekStart={weekStart}
planning={state.planning} planning={state.planning}
onLeftoverDiscarded={handleLeftoverDiscarded} onAddSlot={setOpenSlot}
onRemoveItem={handleRemove}
/>
)}
{openSlot && (
<RecipePickerDialog
slot={openSlot}
onClose={() => setOpenSlot(null)}
onAdded={handleAdded}
/>
)}
{pendingAssign && (
<LeftoverAssignDialog
recipeName={pendingAssign.recipeName}
available={pendingAssign.available}
onCancel={() => setPendingAssign(null)}
onConfirm={confirmAssign}
/> />
)} )}
<button
type="button"
className="planning-page__cook-btn"
disabled={!hasPlannedRecipes}
onClick={() => navigate(`/cuisiner?date=${formatDateOnly(weekStart)}`)}
>
{t("planning.startCooking")}
</button>
</div> </div>
</DndContext>
{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>
); );
} }
@ -203,6 +300,9 @@ function PlanningGrid({
<MealCell <MealCell
key={weekDay} key={weekDay}
isToday={date.hasSame(today, "day")} isToday={date.hasSame(today, "day")}
date={formatDateOnly(date)}
weekDay={weekDay}
meal={meal}
items={items.filter((item) => item.weekDay === weekDay && item.meal === meal)} items={items.filter((item) => item.weekDay === weekDay && item.meal === meal)}
onAdd={() => onAddSlot({ date: formatDateOnly(date), weekDay, meal })} onAdd={() => onAddSlot({ date: formatDateOnly(date), weekDay, meal })}
onRemove={onRemoveItem} onRemove={onRemoveItem}
@ -216,28 +316,55 @@ function PlanningGrid({
); );
} }
/** One (day, meal) cell: the recipes already planned for it (as pills, each showing its portion count) 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. Also a `@dnd-kit` drop target for a leftover card. */
function MealCell({ function MealCell({
isToday, isToday,
date,
weekDay,
meal,
items, items,
onAdd, onAdd,
onRemove, onRemove,
}: { }: {
isToday: boolean; isToday: boolean;
date: string;
weekDay: WeekDay;
meal: Meal;
items: PlanningItemView[]; items: PlanningItemView[];
onAdd: () => void; onAdd: () => void;
onRemove: (item: PlanningItemView) => void; onRemove: (item: PlanningItemView) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { setNodeRef, isOver } = useDroppable({
id: `cell-${weekDay}-${meal}`,
data: { date, weekDay, meal } satisfies CellDropData,
});
const className = ["meal-cell", isToday ? "today" : "", isOver ? "meal-cell--drop-over" : ""]
.filter(Boolean)
.join(" ");
return ( return (
<td className={isToday ? "meal-cell today" : "meal-cell"}> <td ref={setNodeRef} className={className}>
<div className="meal-cell__content"> <div className="meal-cell__content">
{items.length > 0 && ( {items.length > 0 && (
<div className="meal-cell__recipes"> <div className="meal-cell__recipes">
{items.map((item) => ( {items.map((item) => (
<span key={item.id} className="recipe-chip"> <span
key={item.id}
className={
item.sourceItem !== null ? "recipe-chip recipe-chip--leftover" : "recipe-chip"
}
>
<span className="recipe-chip__name"> <span className="recipe-chip__name">
{item.sourceItem !== null && (
<span
className="recipe-chip__leftover-badge"
title={t("planning.leftovers.chipBadge")}
>
♻️
</span>
)}
{item.recipe.name} · ×{item.portions} {item.recipe.name} · ×{item.portions}
</span> </span>
<button <button

View file

@ -161,6 +161,14 @@ table.planning-grid {
// --- Case : pastilles de recette + bouton "+" ------------------------------- // --- Case : pastilles de recette + bouton "+" -------------------------------
.meal-cell { .meal-cell {
padding: var(--space-sm); padding: var(--space-sm);
// Highlighted while a leftover card is dragged over it (@dnd-kit's
// `isOver`) — see PlanningPage's `MealCell` droppable.
&--drop-over {
background: var(--color-tag);
outline: 2px dashed var(--color-primary);
outline-offset: -2px;
}
} }
.meal-cell__content { .meal-cell__content {
@ -197,6 +205,17 @@ table.planning-grid {
white-space: nowrap; white-space: nowrap;
} }
// A "leftovers" chip (`sourceItem !== null`) — served from another
// slot's batch, nothing cooked for it. Dimmer + a ♻️ marker.
&--leftover {
background: var(--color-surface-alt);
color: var(--color-text-muted);
}
&__leftover-badge {
margin-right: 0.15rem;
}
&__remove { &__remove {
flex-shrink: 0; flex-shrink: 0;
width: 1rem; width: 1rem;

View file

@ -102,6 +102,12 @@ importers:
'@batch-cooking/shared': '@batch-cooking/shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared version: link:../../packages/shared
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@18.3.1)
i18next: i18next:
specifier: ^26.3.6 specifier: ^26.3.6
version: 26.3.6(typescript@5.9.3) version: 26.3.6(typescript@5.9.3)
@ -520,6 +526,22 @@ packages:
resolution: {integrity: sha512-Xc3VhU02wqZ1HvHRJUwL09HkZSTvidqY5Ya0NXBSYOxAp+Ln9dcJr9fySI+CkONzP3PekQo9WdzCv0PGER/mOA==, tarball: https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-1.1.0.tgz} resolution: {integrity: sha512-Xc3VhU02wqZ1HvHRJUwL09HkZSTvidqY5Ya0NXBSYOxAp+Ln9dcJr9fySI+CkONzP3PekQo9WdzCv0PGER/mOA==, tarball: https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-1.1.0.tgz}
engines: {node: '>=14.17.0'} engines: {node: '>=14.17.0'}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==, tarball: https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/core@6.3.1':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==, tarball: https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==, tarball: https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz}
peerDependencies:
react: '>=16.8.0'
'@epic-web/invariant@1.0.0': '@epic-web/invariant@1.0.0':
resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==, tarball: https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz} resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==, tarball: https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz}
@ -4384,6 +4406,24 @@ snapshots:
'@discoveryjs/json-ext@1.1.0': {} '@discoveryjs/json-ext@1.1.0': {}
'@dnd-kit/accessibility@3.1.1(react@18.3.1)':
dependencies:
react: 18.3.1
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@18.3.1)
'@dnd-kit/utilities': 3.2.2(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@18.3.1)':
dependencies:
react: 18.3.1
tslib: 2.8.1
'@epic-web/invariant@1.0.0': {} '@epic-web/invariant@1.0.0': {}
'@esbuild/aix-ppc64@0.21.5': '@esbuild/aix-ppc64@0.21.5':

View file

@ -38,7 +38,8 @@ apps/web/src/
│ ├── planning/ │ ├── planning/
│ │ ├── RecipePickerDialog.tsx # dialogue "ajouter au planning" — parcourir/prévisualiser/importer, voir plus bas │ │ ├── RecipePickerDialog.tsx # dialogue "ajouter au planning" — parcourir/prévisualiser/importer, voir plus bas
│ │ ├── recipe-picker-dialog.scss │ │ ├── recipe-picker-dialog.scss
│ │ ├── LeftoversBasket.tsx + leftovers-basket.ts + leftovers-basket.scss # icône + popover des restes de la semaine dans l'en-tête du planning (issue #34) │ │ ├── LeftoversBasket.tsx + leftovers-basket.ts + leftovers-basket.scss # icône + popover des restes de la semaine, cartes draggables (issue #34)
│ │ ├── LeftoverAssignDialog.tsx # étape "combien de portions ?" au dépôt d'un reste sur un créneau
│ │ └── WeekNavigator.tsx + week-navigator.scss # arrows + calendrier de sélection de semaine — partagé par PlanningPage et ShoppingListPage, voir plus bas │ │ └── WeekNavigator.tsx + week-navigator.scss # arrows + calendrier de sélection de semaine — partagé par PlanningPage et ShoppingListPage, voir plus bas
│ └── recipes/ # catalogue, import, édition — voir plus bas ; sous-dossiers par sous-domaine, pas de fichiers à plat │ └── recipes/ # catalogue, import, édition — voir plus bas ; sous-dossiers par sous-domaine, pas de fichiers à plat
│ ├── RecipeTable.tsx / RecipeTabs.tsx / RecipeDetailPanel.tsx # racine : composants transverses au sous-domaine (utilisés par plusieurs des sous-dossiers ci-dessous) │ ├── RecipeTable.tsx / RecipeTabs.tsx / RecipeDetailPanel.tsx # racine : composants transverses au sous-domaine (utilisés par plusieurs des sous-dossiers ci-dessous)
@ -401,9 +402,18 @@ une bordure appuyée après `collation`/`dejeuner`/`gouter`.
`computeAvailableLeftover` (`@batch-cooking/shared`, `leftoverUses` `computeAvailableLeftover` (`@batch-cooking/shared`, `leftoverUses`
reconstitué depuis le tableau plat) est `> 0`. Bouton « Jeter » par reconstitué depuis le tableau plat) est `> 0`. Bouton « Jeter » par
ligne → `PATCH /planning/items/:id/discard-leftover`, la réponse est ligne → `PATCH /planning/items/:id/discard-leftover`, la réponse est
patchée dans `planning.items` par id. Le glisser-déposer d'un reste sur patchée dans `planning.items` par id.
un autre créneau arrive dans une itération ultérieure. Libellés : - **Glisser-déposer** (`@dnd-kit/core`, 1re et seule utilisation de DnD de
`planning.leftovers.*`. l'app) — `PlanningPage` monte un `<DndContext>` (`PointerSensor` avec
distance d'activation 5px pour ne pas voler le clic « Jeter », +
`KeyboardSensor`) autour de la grille et du panier. Chaque ligne du
panier est `useDraggable` (poignée `⠿` dédiée), chaque `MealCell` est
`useDroppable` (`data: { date, weekDay, meal }`, surbrillance `isOver`).
Au drop, `onDragEnd` ouvre `LeftoverAssignDialog` (quantité, défaut =
disponible, plafonné) → `POST /planning/items/leftover` (`apiClient.
assignLeftover`) → nouvel item patché dans `planning.items`. Un item
« restes » (`sourceItem !== null`) porte un badge ♻️ sur sa pastille
(`.recipe-chip--leftover`). Libellés : `planning.leftovers.*`.
### `RecipePickerDialog` — parcourir, prévisualiser, importer ### `RecipePickerDialog` — parcourir, prévisualiser, importer