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(null); const popoverRef = useRef(null); const lines = collectLeftoverLines(planning); useEffect(() => { // On mobile the BottomSheet branch below never attaches this ref (it's // a native 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 ? (

{t("planning.leftovers.empty")}

) : (
    {lines.map(({ item, available }) => ( void discard(item)} /> ))}
); return (
{isOpen && (isMobile ? ( setIsOpen(false)} title={t("planning.leftovers.title")}> {listContent} ) : (

{t("planning.leftovers.title")}

{listContent}
))}
); } /** * 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 `
  • `: a // pointer-based collision test then resolves cleanly to the single cell // under the cursor (a wide `
  • ` 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 (
  • {item.recipe.name} {t("planning.leftovers.origin", { day: t(`common.days.${item.weekDay}`), meal: t(`planning.meals.${item.meal}`), })}
    {t("planning.leftovers.available", { count: available })}
  • ); }