feat: clear a meal-plan day or the whole week in one action

New bulk-delete route for the owner's own weekStart-scoped plan
(mirrors recipes/bulk's DELETE), plus ?ids= support added to the
existing shared-plan DELETE route (kept ?entryId= working
unchanged). UI: hover-reveal trash icon per day header, "Clear week"
button in the toolbar, one shared confirm dialog for both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-13 22:07:12 +02:00
parent a144052e46
commit aa67868b96
9 changed files with 151 additions and 11 deletions
+82 -3
View File
@@ -5,6 +5,16 @@ import Link from "next/link";
import { Plus, Trash2, Sparkles, ChefHat, Check } from "lucide-react";
import { toast } from "sonner";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -190,8 +200,11 @@ export function MealPlanner({
const [pantryMode, setPantryMode] = useState(false);
const [aiDifficulty, setAiDifficulty] = useState<"" | "easy" | "medium" | "hard">("");
const [targetNutritionGoals, setTargetNutritionGoals] = useState(false);
const [clearTarget, setClearTarget] = useState<{ type: "day"; day: Day; label: string } | { type: "week" } | null>(null);
const [clearing, setClearing] = useState(false);
const t = useTranslations("mealPlan");
const tRecipe = useTranslations("recipe");
const tCommon = useTranslations("common");
async function generateWithAi() {
setAiGenerating(true);
@@ -292,6 +305,29 @@ export function MealPlanner({
}
}
async function confirmClear() {
if (!clearTarget) return;
const ids = (
clearTarget.type === "day" ? entries.filter((e) => e.day === clearTarget.day) : entries
).map((e) => e.id);
if (ids.length === 0) { setClearTarget(null); return; }
setClearing(true);
try {
const res = await fetch(`/api/v1/meal-plans/${weekStart}/entries/bulk`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
});
if (!res.ok) { toast.error(t("removeFailed")); return; }
setEntries((prev) => prev.filter((e) => !ids.includes(e.id)));
toast.success(t("clearedSuccess", { count: ids.length }));
} finally {
setClearing(false);
setClearTarget(null);
}
}
async function markCooked(entry: Entry) {
if (!entry.recipe || markingCookedIds.has(entry.id)) return;
setMarkingCookedIds((prev) => new Set(prev).add(entry.id));
@@ -325,6 +361,12 @@ export function MealPlanner({
<>
{/* AI Generate buttons */}
<div className="flex justify-end gap-2 mb-4">
{entries.length > 0 && (
<Button variant="ghost" size="sm" onClick={() => setClearTarget({ type: "week" })} className="gap-2 text-muted-foreground">
<Trash2 className="h-4 w-4" />
{t("clearWeek")}
</Button>
)}
<Button variant="outline" size="sm" onClick={() => setShowBatchModal(true)} className="gap-2">
<ChefHat className="h-4 w-4" />
{t("batchCooking")}
@@ -346,9 +388,25 @@ export function MealPlanner({
<thead>
<tr>
<th className="text-left text-xs font-medium text-muted-foreground pb-3" />
{DAYS.map(({ key, label }) => (
<th key={key} className="text-center text-sm font-semibold pb-3 px-2">{label}</th>
))}
{DAYS.map(({ key, label }) => {
const dayHasEntries = entries.some((e) => e.day === key);
return (
<th key={key} className="text-center text-sm font-semibold pb-3 px-2">
<span className="group inline-flex items-center gap-1">
{label}
{dayHasEntries && (
<button
onClick={() => setClearTarget({ type: "day", day: key, label })}
title={t("clearDay")}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-opacity"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
@@ -541,6 +599,27 @@ export function MealPlanner({
)}
<BatchCookGenerateDialog open={showBatchModal} onOpenChange={setShowBatchModal} />
<AlertDialog open={clearTarget !== null} onOpenChange={(open) => !open && setClearTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{clearTarget?.type === "day" ? t("clearDayConfirmTitle", { day: clearTarget.label }) : t("clearWeekConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>{t("clearConfirmDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={clearing}>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
disabled={clearing}
onClick={() => { void confirmClear(); }}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}