Merge pull request 'feat(planning): glisser-déposer un reste sur un autre créneau' (#51) from feat/leftovers-drag-drop into main
This commit is contained in:
commit
d1d68b0bf7
13 changed files with 615 additions and 80 deletions
24
apps/web/cypress/e2e/planning-leftovers.feature
Normal file
24
apps/web/cypress/e2e/planning-leftovers.feature
Normal 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
|
||||||
132
apps/web/cypress/e2e/planning-leftovers.ts
Normal file
132
apps/web/cypress/e2e/planning-leftovers.ts
Normal 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");
|
||||||
|
});
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
81
apps/web/src/features/planning/LeftoverAssignDialog.tsx
Normal file
81
apps/web/src/features/planning/LeftoverAssignDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,7 +81,61 @@ 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
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
available={available}
|
||||||
|
isDiscarding={discardingId === item.id}
|
||||||
|
onDiscard={() => void discard(item)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</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">
|
<div className="leftovers-basket__meta">
|
||||||
<span className="leftovers-basket__recipe">{item.recipe.name}</span>
|
<span className="leftovers-basket__recipe">{item.recipe.name}</span>
|
||||||
<span className="leftovers-basket__origin">
|
<span className="leftovers-basket__origin">
|
||||||
|
|
@ -95,17 +151,11 @@ export function LeftoversBasket({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="leftovers-basket__discard"
|
className="leftovers-basket__discard"
|
||||||
onClick={() => void discard(item)}
|
onClick={onDiscard}
|
||||||
disabled={discardingId === item.id}
|
disabled={isDiscarding}
|
||||||
>
|
>
|
||||||
{t("planning.leftovers.discard")}
|
{t("planning.leftovers.discard")}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,6 +196,11 @@ 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 (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={dropOnCellUnderPointer}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
<div className="planning-page">
|
<div className="planning-page">
|
||||||
<div className="planning-page__header">
|
<div className="planning-page__header">
|
||||||
<h1>{t("planning.title")}</h1>
|
<h1>{t("planning.title")}</h1>
|
||||||
|
|
@ -160,7 +247,17 @@ export function PlanningPage() {
|
||||||
onAdded={handleAdded}
|
onAdded={handleAdded}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{pendingAssign && (
|
||||||
|
<LeftoverAssignDialog
|
||||||
|
recipeName={pendingAssign.recipeName}
|
||||||
|
available={pendingAssign.available}
|
||||||
|
onCancel={() => setPendingAssign(null)}
|
||||||
|
onConfirm={confirmAssign}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</DndContext>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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':
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue