Files
Epicure/apps/web/components/meal-plan/meal-planner.tsx
T
Arnaud aa67868b96 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>
2026-07-13 22:07:12 +02:00

626 lines
24 KiB
TypeScript

"use client";
import { useState, useCallback } from "react";
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";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useTranslations } from "next-intl";
import { BatchCookGenerateDialog } from "@/components/recipe/batch-cook-generate-dialog";
type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
type MealType = "breakfast" | "lunch" | "dinner" | "snack";
type Recipe = { id: string; title: string };
type BatchDish = { id: string; name: string };
type Entry = {
id: string;
day: Day;
mealType: MealType;
servings: number;
recipe: Recipe | null;
batchDish: BatchDish | null;
note: string | null;
};
type UserRecipe = { id: string; title: string; isBatchCook: boolean; batchDishes: BatchDish[] };
function AddEntryModal({
weekStart,
day,
mealType,
userRecipes,
onAdded,
onClose,
dayLabel,
mealLabel,
}: {
weekStart: string;
day: Day;
mealType: MealType;
userRecipes: UserRecipe[];
onAdded: (entry: Entry) => void;
onClose: () => void;
dayLabel: string;
mealLabel: string;
}) {
const [search, setSearch] = useState("");
const [servings, setServings] = useState("2");
const [saving, setSaving] = useState(false);
const [pickingDishFor, setPickingDishFor] = useState<UserRecipe | null>(null);
const t = useTranslations("mealPlan");
const filtered = userRecipes.filter((r) =>
r.title.toLowerCase().includes(search.toLowerCase())
);
async function add(recipe: UserRecipe, batchDish?: BatchDish) {
setSaving(true);
try {
const res = await fetch(`/api/v1/meal-plans/${weekStart}/entries`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
day,
mealType,
recipeId: recipe.id,
batchDishId: batchDish?.id,
servings: parseInt(servings) || 2,
}),
});
if (!res.ok) { toast.error(t("addFailed")); return; }
const { id } = await res.json() as { id: string };
onAdded({ id, day, mealType, servings: parseInt(servings) || 2, recipe, batchDish: batchDish ?? null, note: null });
onClose();
} finally {
setSaving(false);
}
}
function selectRecipe(recipe: UserRecipe) {
if (recipe.isBatchCook && recipe.batchDishes.length > 0) {
setPickingDishFor(recipe);
return;
}
void add(recipe);
}
if (pickingDishFor) {
return (
<Dialog open onOpenChange={() => onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("pickDishTitle", { recipe: pickingDishFor.title })}</DialogTitle>
<DialogDescription>{t("pickDishDescription")}</DialogDescription>
</DialogHeader>
<div className="max-h-56 overflow-y-auto space-y-1">
{pickingDishFor.batchDishes.map((dish) => (
<button
key={dish.id}
onClick={() => { void add(pickingDishFor, dish); }}
disabled={saving}
className="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition-colors"
>
{dish.name}
</button>
))}
</div>
<Button variant="ghost" size="sm" onClick={() => setPickingDishFor(null)} disabled={saving}>
{t("back")}
</Button>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open onOpenChange={() => onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("addTo", { day: dayLabel, meal: mealLabel })}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<Input
placeholder={t("searchRecipes")}
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
<Input
type="number"
min={1}
max={50}
value={servings}
onChange={(e) => setServings(e.target.value)}
placeholder={t("servings")}
/>
<div className="max-h-56 overflow-y-auto space-y-1">
{filtered.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">{t("noRecipes")}</p>
) : (
filtered.map((recipe) => (
<button
key={recipe.id}
onClick={() => selectRecipe(recipe)}
disabled={saving}
className="w-full flex items-center justify-between gap-2 text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition-colors"
>
<span>{recipe.title}</span>
{recipe.isBatchCook && (
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground border rounded-full px-2 py-0.5">
{t("batchCookBadge")}
</span>
)}
</button>
))
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
export function MealPlanner({
weekStart,
initialEntries,
userRecipes,
hasNutritionGoals = false,
}: {
weekStart: string;
initialEntries: Entry[];
userRecipes: UserRecipe[];
hasNutritionGoals?: boolean;
}) {
const [entries, setEntries] = useState<Entry[]>(initialEntries);
const [cookedIds, setCookedIds] = useState<Set<string>>(new Set());
const [markingCookedIds, setMarkingCookedIds] = useState<Set<string>>(new Set());
const [adding, setAdding] = useState<{ day: Day; mealType: MealType } | null>(null);
const [showAiModal, setShowAiModal] = useState(false);
const [showBatchModal, setShowBatchModal] = useState(false);
const [aiGenerating, setAiGenerating] = useState(false);
const [aiPhase, setAiPhase] = useState("");
const [dietaryPrefs, setDietaryPrefs] = useState("");
const [aiServings, setAiServings] = useState("2");
const [usePantry, setUsePantry] = useState(true);
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);
setAiPhase(t("aiPhaseAnalyzing"));
const phases = [
[1500, t("aiPhasePlanning")],
[5000, t("aiPhaseSelecting")],
[10000, t("aiPhaseBalancing")],
[16000, t("aiPhaseFinalizing")],
] as const;
const timers = phases.map(([delay, label]) =>
setTimeout(() => setAiPhase(label), delay)
);
try {
const res = await fetch("/api/v1/ai/meal-plan/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
weekStart,
dietaryPrefs: dietaryPrefs.trim() || undefined,
servings: parseInt(aiServings) || 2,
usePantry,
pantryMode,
difficulty: aiDifficulty || undefined,
targetNutritionGoals: hasNutritionGoals && targetNutritionGoals,
}),
});
if (!res.ok) {
const data = await res.json() as { error?: string };
throw new Error(data.error ?? t("generationFailed"));
}
const data = await res.json() as {
entries: Array<{ id: string; day: string; mealType: string; recipeId: string; recipeTitle: string }>;
};
// Merge generated entries into state
setEntries((prev) => {
const updated = [...prev];
for (const e of data.entries) {
const idx = updated.findIndex((u) => u.day === e.day && u.mealType === e.mealType);
const newEntry: Entry = {
id: e.id,
day: e.day as Day,
mealType: e.mealType as MealType,
servings: parseInt(aiServings) || 2,
recipe: { id: e.recipeId, title: e.recipeTitle },
batchDish: null,
note: null,
};
if (idx >= 0) updated[idx] = newEntry;
else updated.push(newEntry);
}
return updated;
});
setShowAiModal(false);
toast.success(t("aiGenerated"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("generationFailed"));
} finally {
timers.forEach(clearTimeout);
setAiGenerating(false);
setAiPhase("");
}
}
const DAYS: { key: Day; label: string }[] = [
{ key: "mon", label: t("days.mon") },
{ key: "tue", label: t("days.tue") },
{ key: "wed", label: t("days.wed") },
{ key: "thu", label: t("days.thu") },
{ key: "fri", label: t("days.fri") },
{ key: "sat", label: t("days.sat") },
{ key: "sun", label: t("days.sun") },
];
const MEAL_TYPES: { key: MealType; label: string }[] = [
{ key: "breakfast", label: t("meals.breakfast") },
{ key: "lunch", label: t("meals.lunch") },
{ key: "dinner", label: t("meals.dinner") },
{ key: "snack", label: t("meals.snack") },
];
const getEntry = (day: Day, mealType: MealType) =>
entries.find((e) => e.day === day && e.mealType === mealType);
const handleAdded = useCallback((entry: Entry) => {
setEntries((prev) => [
...prev.filter((e) => !(e.day === entry.day && e.mealType === entry.mealType)),
entry,
]);
}, []);
async function removeEntry(entry: Entry) {
const res = await fetch(`/api/v1/meal-plans/${weekStart}/entries/${entry.id}`, { method: "DELETE" });
if (res.ok) {
setEntries((prev) => prev.filter((e) => e.id !== entry.id));
} else {
toast.error(t("removeFailed"));
}
}
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));
try {
const res = await fetch(`/api/v1/recipes/${entry.recipe.id}/cooked`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ servings: entry.servings, deductFromPantry: false }),
});
if (!res.ok) {
toast.error(t("markCookedFailed"));
return;
}
setCookedIds((prev) => new Set(prev).add(entry.id));
toast.success(t("markCookedSuccess"));
} catch {
toast.error(t("markCookedFailed"));
} finally {
setMarkingCookedIds((prev) => {
const next = new Set(prev);
next.delete(entry.id);
return next;
});
}
}
const addingDay = adding ? DAYS.find((d) => d.key === adding.day) : null;
const addingMeal = adding ? MEAL_TYPES.find((m) => m.key === adding.mealType) : null;
return (
<>
{/* 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")}
</Button>
<Button size="sm" onClick={() => setShowAiModal(true)} className="gap-2">
<Sparkles className="h-4 w-4" />
{t("generateWithAi")}
</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] border-collapse table-fixed">
<colgroup>
<col className="w-24" />
{DAYS.map(({ key }) => (
<col key={key} />
))}
</colgroup>
<thead>
<tr>
<th className="text-left text-xs font-medium text-muted-foreground pb-3" />
{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>
{MEAL_TYPES.map(({ key: mealType, label }) => (
<tr key={mealType} className="border-t">
<td className="py-3 pr-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{label}</span>
</td>
{DAYS.map(({ key: day }) => {
const entry = getEntry(day, mealType);
return (
<td key={day} className="py-2 px-1 align-top">
{entry?.recipe ? (
<div className="group relative rounded-lg bg-primary/10 border border-primary/20 p-2 text-xs min-h-[52px]">
<Link
href={`/recipes/${entry.recipe.id}`}
className="block pr-8 hover:underline"
>
<div className="font-medium line-clamp-2">{entry.batchDish?.name ?? entry.recipe.title}</div>
<div className="text-muted-foreground mt-0.5 flex items-center gap-1">
{t("servingsAbbrev", { count: entry.servings })}
{cookedIds.has(entry.id) && (
<Check className="h-3 w-3 text-green-600 shrink-0" />
)}
</div>
</Link>
<div className="absolute top-1 right-1 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => markCooked(entry)}
disabled={markingCookedIds.has(entry.id)}
title={t("markCooked")}
className="text-muted-foreground hover:text-primary disabled:opacity-50"
>
<ChefHat className="h-3 w-3" />
</button>
<button
onClick={() => removeEntry(entry)}
title={t("removeEntry")}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
) : (
<button
onClick={() => setAdding({ day, mealType })}
className="w-full min-h-[52px] rounded-lg border-2 border-dashed border-muted-foreground/20 hover:border-muted-foreground/40 flex items-center justify-center transition-colors"
>
<Plus className="h-3.5 w-3.5 text-muted-foreground/40" />
</button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{adding && addingDay && addingMeal && (
<AddEntryModal
weekStart={weekStart}
day={adding.day}
mealType={adding.mealType}
userRecipes={userRecipes}
onAdded={handleAdded}
onClose={() => setAdding(null)}
dayLabel={addingDay.label}
mealLabel={addingMeal.label}
/>
)}
{/* AI generation modal */}
{showAiModal && (
<Dialog open onOpenChange={(open) => { if (!aiGenerating) setShowAiModal(open); }}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
{t("generateWithAi")}
</DialogTitle>
<DialogDescription>{t("aiModalDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("dietaryPrefs")}</label>
<Input
placeholder={t("dietaryPrefsPlaceholder")}
value={dietaryPrefs}
onChange={(e) => setDietaryPrefs(e.target.value)}
disabled={aiGenerating}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("servings")}</label>
<Input
type="number"
min={1}
max={20}
value={aiServings}
onChange={(e) => setAiServings(e.target.value)}
disabled={aiGenerating}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("difficulty")}</label>
<Select value={aiDifficulty} onValueChange={(v) => setAiDifficulty(v as typeof aiDifficulty)} disabled={aiGenerating}>
<SelectTrigger>
<SelectValue placeholder={t("anyDifficulty")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{t("anyDifficulty")}</SelectItem>
<SelectItem value="easy">{tRecipe("difficulty.easy")}</SelectItem>
<SelectItem value="medium">{tRecipe("difficulty.medium")}</SelectItem>
<SelectItem value="hard">{tRecipe("difficulty.hard")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
className="h-4 w-4"
checked={usePantry}
onChange={(e) => {
setUsePantry(e.target.checked);
if (!e.target.checked) setPantryMode(false);
}}
disabled={aiGenerating}
/>
<span className="text-sm font-medium">{t("includePantryItems")}</span>
</label>
{usePantry && (
<label className="flex items-start gap-2 cursor-pointer select-none pl-6">
<input
type="checkbox"
className="h-4 w-4 mt-0.5"
checked={pantryMode}
onChange={(e) => setPantryMode(e.target.checked)}
disabled={aiGenerating}
/>
<span className="space-y-0.5">
<span className="text-sm font-medium block">{t("pantryMode")}</span>
<span className="text-sm text-muted-foreground block">{t("pantryModeDescription")}</span>
</span>
</label>
)}
</div>
{hasNutritionGoals && (
<label className="flex items-start gap-2 cursor-pointer select-none">
<input
type="checkbox"
className="h-4 w-4 mt-0.5"
checked={targetNutritionGoals}
onChange={(e) => setTargetNutritionGoals(e.target.checked)}
disabled={aiGenerating}
/>
<span className="space-y-0.5">
<span className="text-sm font-medium block">{t("targetNutritionGoals")}</span>
<span className="text-sm text-muted-foreground block">{t("targetNutritionGoalsDescription")}</span>
</span>
</label>
)}
<FakeProgressBar active={aiGenerating} durationMs={20000} label={aiPhase} />
<div className="flex gap-2 justify-end">
<Button variant="ghost" onClick={() => setShowAiModal(false)} disabled={aiGenerating}>
{t("cancel")}
</Button>
<Button onClick={() => { void generateWithAi(); }} disabled={aiGenerating} className="gap-2">
{aiGenerating ? (
<>
<span className="animate-spin inline-block h-3.5 w-3.5 border-2 border-current border-t-transparent rounded-full" />
{t("generating")}
</>
) : (
<>
<Sparkles className="h-3.5 w-3.5" />
{t("generate")}
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)}
<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>
</>
);
}