PR 3/4 du chantier « gestion des restes » (issue #34). `LeftoversBasket` (`features/planning/`) : une icône 🧺 avec badge de compte dans l'en-tête du planning, à côté de `WeekNavigator`. Ouvre un popover listant les restes encore disponibles de la semaine affichée — même patron montage conditionnel + fermeture au `mousedown` extérieur que `CalendarPopover`. Les lignes sont dérivées à chaque rendu de `planning.items` par `collectLeftoverLines` (`leftovers-basket.ts`, pur) : chaque item « frais » (`sourceItem === null`) dont `computeAvailableLeftover` est `> 0`, le `leftoverUses` étant reconstitué depuis le tableau plat (les items « restes » qui pointent vers lui) — aucun aller-retour API en plus. Chaque ligne : recette, jour/repas d'origine, portions restantes, bouton « Jeter » → `PATCH /planning/items/:id/discard-leftover` (`apiClient. discardLeftover`), la réponse est patchée dans `planning.items` par id (`handleLeftoverDiscarded`). État vide quand rien ne reste. Le glisser-déposer d'un reste sur un autre créneau arrive en PR 4. Tests : `leftovers-basket.cy.ts` (mode layout) — badge de compte, ouverture / fermeture au clic extérieur, contenu d'une ligne, état vide, « Jeter » qui appelle le PATCH et retire la ligne. Vérifié localement, 3/3. i18n : `planning.leftovers.*`. Docs : `frontend-architecture.md`. Réf #34 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
108 lines
3.5 KiB
TypeScript
108 lines
3.5 KiB
TypeScript
// Layout-mode spec for the planning header's leftovers basket — mocks
|
|
// `GET /planning` (this job has no live backend; apps/api's Mocha suite
|
|
// covers the real endpoints). Exercises the popover open/close, an
|
|
// outside click, and the "Jeter" button firing `PATCH .../discard-leftover`.
|
|
|
|
const authenticatedProfile = {
|
|
id: 1,
|
|
firstName: "Alice",
|
|
lastName: "Martin",
|
|
email: "alice@example.com",
|
|
tokenVersion: 0,
|
|
houseId: 1,
|
|
dietId: null,
|
|
};
|
|
|
|
/**
|
|
* A week with one fresh item that cooked 12 for 10 requested (pool of 2)
|
|
* and one leftovers item serving 1 of that pool — so the basket should
|
|
* offer 1 line with 1 portion still available.
|
|
*/
|
|
const planningWithLeftovers = {
|
|
id: 1,
|
|
startDate: "2026-08-17T00:00:00.000Z",
|
|
finishDate: "2026-08-23T00:00:00.000Z",
|
|
items: [
|
|
{
|
|
id: 1,
|
|
weekDay: "lundi",
|
|
meal: "diner",
|
|
portions: 12,
|
|
portionsRequested: 10,
|
|
discardedPortions: 0,
|
|
sourceItem: null,
|
|
recipe: { id: 1, name: "Lasagnes" },
|
|
},
|
|
{
|
|
id: 2,
|
|
weekDay: "mardi",
|
|
meal: "dejeuner",
|
|
portions: 1,
|
|
portionsRequested: 1,
|
|
discardedPortions: 0,
|
|
sourceItem: { id: 1 },
|
|
recipe: { id: 1, name: "Lasagnes" },
|
|
},
|
|
],
|
|
};
|
|
|
|
describe("Leftovers basket", () => {
|
|
beforeEach(() => {
|
|
cy.viewport(1600, 900);
|
|
cy.clock(new Date("2026-08-17T09:00:00Z"), ["Date"]);
|
|
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
|
});
|
|
|
|
it("lists a week's available leftovers, opens and closes on an outside click", () => {
|
|
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: planningWithLeftovers });
|
|
cy.visit("/");
|
|
|
|
// Count badge = 1 fresh item with a remaining pool.
|
|
cy.get(".leftovers-basket__count").should("have.text", "1");
|
|
|
|
cy.get(".leftovers-basket__toggle").click();
|
|
cy.contains(".leftovers-basket__heading", "Restes disponibles").should("be.visible");
|
|
cy.contains(".leftovers-basket__line", "Lasagnes")
|
|
.should("contain.text", "Lundi")
|
|
.and("contain.text", "1 en restes");
|
|
|
|
// Clicking away from the popover closes it.
|
|
cy.get("h1").click();
|
|
cy.get(".leftovers-basket__popover").should("not.exist");
|
|
});
|
|
|
|
it("shows an empty state when nothing is left over", () => {
|
|
cy.intercept("GET", /\/planning\?/, {
|
|
statusCode: 200,
|
|
body: {
|
|
...planningWithLeftovers,
|
|
items: [{ ...planningWithLeftovers.items[0], portions: 10, portionsRequested: 10 }],
|
|
},
|
|
});
|
|
cy.visit("/");
|
|
|
|
cy.get(".leftovers-basket__count").should("not.exist");
|
|
cy.get(".leftovers-basket__toggle").click();
|
|
cy.contains(".leftovers-basket__empty", "Aucun reste cette semaine.").should("be.visible");
|
|
});
|
|
|
|
it("throws away a leftover pool via PATCH and drops it from the list", () => {
|
|
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: planningWithLeftovers });
|
|
cy.intercept("PATCH", "**/planning/items/1/discard-leftover", {
|
|
statusCode: 200,
|
|
body: {
|
|
...planningWithLeftovers.items[0],
|
|
discardedPortions: 1,
|
|
},
|
|
}).as("discard");
|
|
|
|
cy.visit("/");
|
|
cy.get(".leftovers-basket__toggle").click();
|
|
cy.contains(".leftovers-basket__line", "Lasagnes").find(".leftovers-basket__discard").click();
|
|
|
|
cy.wait("@discard");
|
|
// Pool was 1, now fully discarded -> the line is gone, the popover is empty.
|
|
cy.contains(".leftovers-basket__empty", "Aucun reste cette semaine.").should("be.visible");
|
|
cy.get(".leftovers-basket__count").should("not.exist");
|
|
});
|
|
});
|