Files
Epicure/apps/web/components/recipe/mark-cooked-dialog.tsx
T
Arnaud 93936eae10 feat: pantry notes/categories, ingredient-alias matching, cook-log edit/delete, fork-list popover (v0.83.0)
Pantry: notes + category fields (collapsible grouping like the shopping list), a "Merge duplicates" cleanup action, and fixed quantity display precision (was showing raw decimal(10,4) strings like "0.3333 kg" everywhere — pantry, shopping list, print views, Markdown exports).

Ingredient-alias matching: the ingredients table (canonical name + aliases) existed but was never populated or used. Seeded ~10 bilingual EN/FR staples and wired resolution into pantry add/edit, can-cook scoring, auto-deduct-on-cook, and shopping-list pantry-awareness, so "sel"/"sel fin"/"table salt" are recognized as the same ingredient.

Cook log: entries from "Mark cooked" can now be edited and deleted (previously log-only, no fix-a-mistake path). The "Cooked N times" text is a hover tooltip listing every date and opens a full manage sheet on click.

Also: the "Forked by N others" backlink is now a click-to-open popover instead of an always-inline list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 15:13:33 +02:00

121 lines
4.5 KiB
TypeScript

"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?: (log: { id: string; cookedAt: string; servings: number }) => 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();
const { id } = await res.json() as { id: string };
toast.success(t("markCookedSuccess"));
setOpen(false);
onLogged?.({ id, cookedAt: date, servings });
} 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>
</>
);
}