batchCooking/apps/web/src/features/planning/LeftoversBasket.tsx
Nicolas a3701acae4
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 3m18s
CI / intent-service-test (push) Successful in 4m8s
CI / build (push) Successful in 3m42s
CI / test (push) Failing after 10m11s
CI / e2e (push) Has been cancelled
fix(planning): popovers flottants surs sur mobile
Ticket #87 du chantier UI mobile & tablette. LeftoversBasket et
WeekNavigator's CalendarPopover etaient tous les deux des popovers a
largeur fixe (18-20rem), ancres right: 0, sans aucune gestion de bord
d'ecran - sur un telephone etroit ils pouvaient deborder hors du
viewport. Les deux sont declenches depuis l'en-tete du planning, un
ecran central de l'usage quotidien - tres probablement la cause
concrete derriere le retour terrain "je n'ai pas acces a tous les
boutons" sur mobile qui a lance ce chantier.

Les deux basculent desormais sur BottomSheet (ticket #91) sous le
breakpoint phone via useIsMobileViewport() - meme contenu, juste une
autre enveloppe (le popover flottant + son ecouteur de clic exterieur
manuel restent inchanges sur desktop). Le clic-exterieur devient un
no-op propre sur mobile plutot qu'un ecouteur qui ne fait jamais rien
(le ref ne s'attache a rien dans la branche BottomSheet).

Nouvelle cle i18n common.calendar.title ("Choisir une semaine") pour
le titre de la feuille - pas de raccourci en decoupant une autre cle
interpolee.

Verifie en local : tsc -b --force, biome check, build de prod, suite
Cypress e2e complete (142/142, 2 nouveaux tests phone dans
leftovers-basket.cy.ts et planning-page.cy.ts) et composants (63/63).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:48:39 +02:00

184 lines
6.8 KiB
TypeScript

import type { PlanningItemView, PlanningView } from "@batch-cooking/shared";
import { useDraggable } from "@dnd-kit/core";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { apiClient } from "../../api/client";
import { BottomSheet } from "../../components/ui/BottomSheet";
import { useIsMobileViewport } from "../../lib/useIsMobileViewport";
import { collectLeftoverLines, dragIdForLeftover } 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.
*
* Each line is also draggable (`@dnd-kit`, see {@link LeftoverLineCard})
* onto a planning cell to serve part of its pool there — `PlanningPage`
* owns the `DndContext` and the drop handling. 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 or assignment elsewhere on the page is reflected here for free.
*
* On a phone this renders inside a {@link BottomSheet} instead of the
* plain anchored `div` below — the fixed-width popover (`right: 0`, no
* edge handling) used to render identically at every width, which could
* push it partly or fully off a narrow screen (see the mobile/tablet UI
* chantier's own audit — very plausibly *the* concrete cause of reported
* "can't reach a button" complaints on mobile).
*/
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 isMobile = useIsMobileViewport();
const [isOpen, setIsOpen] = useState(false);
const [discardingId, setDiscardingId] = useState<number | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const lines = collectLeftoverLines(planning);
useEffect(() => {
// On mobile the BottomSheet branch below never attaches this ref (it's
// a native <dialog> with its own tap-outside handling instead) —
// `popoverRef.current` would just stay `null` and this would silently
// no-op every time, harmless but pointless, so skip attaching the
// listener at all in that case.
if (!isOpen || isMobile) 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, isMobile]);
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);
}
}
const listContent =
lines.length === 0 ? (
<p className="leftovers-basket__empty">{t("planning.leftovers.empty")}</p>
) : (
<ul className="leftovers-basket__list">
{lines.map(({ item, available }) => (
<LeftoverLineCard
key={item.id}
item={item}
available={available}
isDiscarding={discardingId === item.id}
onDiscard={() => void discard(item)}
/>
))}
</ul>
);
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 &&
(isMobile ? (
<BottomSheet onClose={() => setIsOpen(false)} title={t("planning.leftovers.title")}>
{listContent}
</BottomSheet>
) : (
<div className="leftovers-basket__popover" ref={popoverRef}>
<h2 className="leftovers-basket__heading">{t("planning.leftovers.title")}</h2>
{listContent}
</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>
);
}