feat: mark recipe as cooked (multi-cook history + backdate) and enable auto-deduct pantry on cook (v0.75.0)
Adds a general-purpose "mark cooked" dialog for any recipe (not just batch-cook dishes), with a date picker for backdating and a pantry-deduct checkbox defaulted on. Also flips the previously dead-in-the-UI deductFromPantry flag to true for the existing batch-cook and meal-planner cook actions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { cloneElement, isValidElement, useState, type ReactElement } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { ChefHat } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
interface MarkCookedDialogProps {
|
||||
recipeId: string;
|
||||
baseServings: number;
|
||||
batchDishId?: string;
|
||||
trigger: React.ReactNode;
|
||||
onLogged?: (cookedAt: string) => void;
|
||||
}
|
||||
|
||||
/** Logs a cook event — date, servings, and whether to deduct matching
|
||||
* ingredients from the pantry (default on). A recipe can be logged as
|
||||
* cooked any number of times; each submission is a new row, never an
|
||||
* update. */
|
||||
export function MarkCookedDialog({ recipeId, baseServings, batchDishId, trigger, onLogged }: MarkCookedDialogProps) {
|
||||
const t = useTranslations("recipe");
|
||||
const tCommon = useTranslations("common");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [date, setDate] = useState(todayIso());
|
||||
const [servings, setServings] = useState(baseServings);
|
||||
const [deductFromPantry, setDeductFromPantry] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/recipes/${recipeId}/cooked`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
servings,
|
||||
cookedAt: date,
|
||||
deductFromPantry,
|
||||
...(batchDishId ? { batchDishId } : {}),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
toast.success(t("markCookedSuccess"));
|
||||
setOpen(false);
|
||||
onLogged?.(date);
|
||||
} catch {
|
||||
toast.error(t("markCookedFailed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const triggerElement = isValidElement(trigger)
|
||||
? cloneElement(trigger as ReactElement<{ onClick?: () => void }>, { onClick: () => setOpen(true) })
|
||||
: trigger;
|
||||
|
||||
return (
|
||||
<>
|
||||
{triggerElement}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ChefHat className="h-5 w-5 text-primary" />
|
||||
{t("markCookedTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("markCookedDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mark-cooked-date">{t("markCookedDateLabel")}</Label>
|
||||
<Input id="mark-cooked-date" type="date" value={date} max={todayIso()} onChange={(e) => setDate(e.target.value || todayIso())} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mark-cooked-servings">{t("markCookedServingsLabel")}</Label>
|
||||
<Input
|
||||
id="mark-cooked-servings"
|
||||
type="number"
|
||||
min={1}
|
||||
value={servings}
|
||||
onChange={(e) => setServings(Math.max(1, Number(e.target.value) || baseServings))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||
<div>
|
||||
<Label htmlFor="mark-cooked-deduct">{t("markCookedDeductLabel")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("markCookedDeductDescription")}</p>
|
||||
</div>
|
||||
<Switch id="mark-cooked-deduct" checked={deductFromPantry} onCheckedChange={setDeductFromPantry} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => { void handleSubmit(); }} disabled={submitting}>
|
||||
{submitting ? t("markCookedSaving") : t("markCookedSubmit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user