feat(planning): panier des restes dans l'en-tête (lecture seule + jeter) #50
8 changed files with 413 additions and 0 deletions
108
apps/web/cypress/e2e/leftovers-basket.cy.ts
Normal file
108
apps/web/cypress/e2e/leftovers-basket.cy.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
// 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -166,6 +166,11 @@ export class ApiClient {
|
||||||
return this._request(`/planning/items/${id}`, { method: "DELETE" });
|
return this._request(`/planning/items/${id}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Throws away planning item `id`'s whole remaining leftover pool (`discardedPortions += available`) — idempotent, returns the updated item. Rejects with `PLANNING_ITEM_NOT_FOUND`. */
|
||||||
|
public discardLeftover(id: number): Promise<PlanningItemView> {
|
||||||
|
return this._request(`/planning/items/${id}/discard-leftover`, { method: "PATCH" });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
|
|
|
||||||
111
apps/web/src/features/planning/LeftoversBasket.tsx
Normal file
111
apps/web/src/features/planning/LeftoversBasket.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import type { PlanningItemView, PlanningView } from "@batch-cooking/shared";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { apiClient } from "../../api/client";
|
||||||
|
import { collectLeftoverLines } from "./leftovers-basket";
|
||||||
|
import "./leftovers-basket.scss";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The planning header's leftovers basket — an icon (with a count badge)
|
||||||
|
* that opens a popover listing this week's still-available leftover pools:
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Read-only for now: dragging a leftover onto another slot arrives in a
|
||||||
|
* later iteration. Same conditional-mount + outside-click-to-close pattern
|
||||||
|
* as `WeekNavigator`'s `CalendarPopover`. The list is derived on every
|
||||||
|
* render from `planning` (see {@link collectLeftoverLines}) rather than
|
||||||
|
* held in its own state, so a discard elsewhere on the page is reflected
|
||||||
|
* here for free.
|
||||||
|
*/
|
||||||
|
export function LeftoversBasket({
|
||||||
|
planning,
|
||||||
|
onLeftoverDiscarded,
|
||||||
|
}: {
|
||||||
|
planning: PlanningView | null;
|
||||||
|
/** The updated item as returned by `PATCH .../discard-leftover` — the parent patches it into its planning state. */
|
||||||
|
onLeftoverDiscarded: (item: PlanningItemView) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [discardingId, setDiscardingId] = useState<number | null>(null);
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const lines = collectLeftoverLines(planning);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
async function discard(item: PlanningItemView) {
|
||||||
|
setDiscardingId(item.id);
|
||||||
|
try {
|
||||||
|
const updated = await apiClient.discardLeftover(item.id);
|
||||||
|
onLeftoverDiscarded(updated);
|
||||||
|
} catch {
|
||||||
|
// Leave the line in place — the user can retry. The error surface
|
||||||
|
// here is intentionally minimal: nothing is lost by a failed discard.
|
||||||
|
} finally {
|
||||||
|
setDiscardingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="leftovers-basket">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="leftovers-basket__toggle"
|
||||||
|
onClick={() => setIsOpen((open) => !open)}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-label={t("planning.leftovers.tooltip")}
|
||||||
|
title={t("planning.leftovers.tooltip")}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">🧺</span>
|
||||||
|
{lines.length > 0 && <span className="leftovers-basket__count">{lines.length}</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="leftovers-basket__popover" ref={popoverRef}>
|
||||||
|
<h2 className="leftovers-basket__heading">{t("planning.leftovers.title")}</h2>
|
||||||
|
{lines.length === 0 ? (
|
||||||
|
<p className="leftovers-basket__empty">{t("planning.leftovers.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="leftovers-basket__list">
|
||||||
|
{lines.map(({ item, available }) => (
|
||||||
|
<li key={item.id} className="leftovers-basket__line">
|
||||||
|
<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={() => void discard(item)}
|
||||||
|
disabled={discardingId === item.id}
|
||||||
|
>
|
||||||
|
{t("planning.leftovers.discard")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
apps/web/src/features/planning/leftovers-basket.scss
Normal file
116
apps/web/src/features/planning/leftovers-basket.scss
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
// Planning header's leftovers basket — an icon button that toggles a
|
||||||
|
// popover of this week's still-available leftover pools. Same "absolutely
|
||||||
|
// positioned panel hanging off a relative wrapper" shape as
|
||||||
|
// `.calendar-popover` (week-navigator.scss).
|
||||||
|
.leftovers-basket {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&__toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small pill showing how many source items still have leftovers.
|
||||||
|
&__count {
|
||||||
|
min-width: 1.1rem;
|
||||||
|
padding: 0 var(--space-xs);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--space-xs));
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
width: 20rem;
|
||||||
|
padding: var(--space-md);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__heading {
|
||||||
|
margin: 0 0 var(--space-sm);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__recipe {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__origin {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__qty {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__discard {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: var(--color-error);
|
||||||
|
border-color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
apps/web/src/features/planning/leftovers-basket.ts
Normal file
39
apps/web/src/features/planning/leftovers-basket.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import {
|
||||||
|
computeAvailableLeftover,
|
||||||
|
type PlanningItemView,
|
||||||
|
type PlanningView,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
|
|
||||||
|
/** One row of the leftovers basket — a fresh planning item that still has an unassigned, undiscarded portion pool. */
|
||||||
|
export interface LeftoverLine {
|
||||||
|
item: PlanningItemView;
|
||||||
|
/** Portions still available to serve elsewhere or throw away — always `> 0` for a line that made it into the list. */
|
||||||
|
available: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The leftovers-basket rows for a week's planning: every **fresh** item
|
||||||
|
* (`sourceItem === null`) whose leftover pool still has something in it,
|
||||||
|
* newest slots first (grid order). Pure — the `leftoverUses` each item
|
||||||
|
* needs for {@link computeAvailableLeftover} is reconstructed from the same
|
||||||
|
* flat `planning.items` array (the "leftovers" items that point back at it),
|
||||||
|
* so no extra API round-trip: the exact "one formula, two callers" split
|
||||||
|
* `computeAvailableLeftover`'s own doc comment describes.
|
||||||
|
*/
|
||||||
|
export function collectLeftoverLines(planning: PlanningView | null): LeftoverLine[] {
|
||||||
|
const items = planning?.items ?? [];
|
||||||
|
return items
|
||||||
|
.filter((item) => item.sourceItem === null)
|
||||||
|
.map((item) => ({
|
||||||
|
item,
|
||||||
|
available: computeAvailableLeftover({
|
||||||
|
portions: item.portions,
|
||||||
|
portionsRequested: item.portionsRequested,
|
||||||
|
discardedPortions: item.discardedPortions,
|
||||||
|
leftoverUses: items
|
||||||
|
.filter((other) => other.sourceItem?.id === item.id)
|
||||||
|
.map((other) => ({ portions: other.portions })),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
.filter((line) => line.available > 0);
|
||||||
|
}
|
||||||
|
|
@ -125,6 +125,14 @@
|
||||||
"title": "Planning de la semaine",
|
"title": "Planning de la semaine",
|
||||||
"startCooking": "Commencer à cuisiner",
|
"startCooking": "Commencer à cuisiner",
|
||||||
"loading": "Chargement du planning…",
|
"loading": "Chargement du planning…",
|
||||||
|
"leftovers": {
|
||||||
|
"tooltip": "Restes de la semaine",
|
||||||
|
"title": "Restes disponibles",
|
||||||
|
"empty": "Aucun reste cette semaine.",
|
||||||
|
"origin": "{{day}} · {{meal}}",
|
||||||
|
"available": "{{count}} en restes",
|
||||||
|
"discard": "Jeter"
|
||||||
|
},
|
||||||
"meals": {
|
"meals": {
|
||||||
"petit-dejeuner": "Petit-déjeuner",
|
"petit-dejeuner": "Petit-déjeuner",
|
||||||
"collation": "Collation",
|
"collation": "Collation",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ 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 { apiClient } from "../../api/client";
|
||||||
|
import { LeftoversBasket } from "../../features/planning/LeftoversBasket";
|
||||||
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 "./planning-page.scss";
|
import "./planning-page.scss";
|
||||||
|
|
@ -93,6 +94,11 @@ export function PlanningPage() {
|
||||||
patchPlanningItems((items) => [...items, item]);
|
patchPlanningItems((items) => [...items, item]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `PATCH .../discard-leftover` returns the updated item — swap it in by id so the basket recomputes. */
|
||||||
|
function handleLeftoverDiscarded(updated: PlanningItemView) {
|
||||||
|
patchPlanningItems((items) => items.map((i) => (i.id === updated.id ? updated : i)));
|
||||||
|
}
|
||||||
|
|
||||||
/** 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));
|
||||||
|
|
@ -112,6 +118,12 @@ export function PlanningPage() {
|
||||||
<div className="planning-page__header">
|
<div className="planning-page__header">
|
||||||
<h1>{t("planning.title")}</h1>
|
<h1>{t("planning.title")}</h1>
|
||||||
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
|
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
|
||||||
|
{state.status === "loaded" && (
|
||||||
|
<LeftoversBasket
|
||||||
|
planning={state.planning}
|
||||||
|
onLeftoverDiscarded={handleLeftoverDiscarded}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="planning-page__cook-btn"
|
className="planning-page__cook-btn"
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ 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)
|
||||||
│ │ └── 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)
|
||||||
|
|
@ -390,6 +391,19 @@ une bordure appuyée après `collation`/`dejeuner`/`gouter`.
|
||||||
immédiatement, `DELETE /planning/items/:id`, restauré en cas d'échec).
|
immédiatement, `DELETE /planning/items/:id`, restauré en cas d'échec).
|
||||||
- Chaque recette planifiée s'affiche en pastille `"{nom} · ×{portions}"` avec
|
- Chaque recette planifiée s'affiche en pastille `"{nom} · ×{portions}"` avec
|
||||||
un bouton de retrait (✕).
|
un bouton de retrait (✕).
|
||||||
|
- **Panier de restes** — `LeftoversBasket` (`features/planning/`) dans
|
||||||
|
l'en-tête, à côté de `WeekNavigator` : une icône 🧺 avec un badge de
|
||||||
|
compte qui ouvre un popover (même patron montage conditionnel +
|
||||||
|
`mousedown` extérieur que `CalendarPopover`) listant les restes encore
|
||||||
|
disponibles de la semaine. Les lignes sont dérivées de `planning.items`
|
||||||
|
à chaque rendu par `collectLeftoverLines` (`leftovers-basket.ts`, pur) :
|
||||||
|
chaque item « frais » (`sourceItem === null`) dont
|
||||||
|
`computeAvailableLeftover` (`@batch-cooking/shared`, `leftoverUses`
|
||||||
|
reconstitué depuis le tableau plat) est `> 0`. Bouton « Jeter » par
|
||||||
|
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
|
||||||
|
un autre créneau arrive dans une itération ultérieure. Libellés :
|
||||||
|
`planning.leftovers.*`.
|
||||||
|
|
||||||
### `RecipePickerDialog` — parcourir, prévisualiser, importer
|
### `RecipePickerDialog` — parcourir, prévisualiser, importer
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue