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
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>
184 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}
|