93936eae10
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>
121 lines
4.5 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|