"use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { Plus, Trash2, AlertTriangle, Package, Pencil, ChevronDown, Merge, Sparkles, Loader2, GripVertical } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { formatQuantity, hasQuantity } from "@/lib/fractions"; import { guessAisle, GROCERY_CATEGORIES, type GroceryCategory } from "@/lib/grocery-categories"; import { PantryScanDialog } from "@/components/pantry/pantry-scan-dialog"; import { PantryItemDialog, type PantryItem } from "@/components/pantry/pantry-item-dialog"; import { EmptyState } from "@/components/shared/empty-state"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { DndContext, type DragEndEvent, PointerSensor, useSensor, useSensors, useDraggable, useDroppable, } from "@dnd-kit/core"; const OTHER_KEY = "__other__"; const ALL_CATEGORY_KEYS: string[] = [...GROCERY_CATEGORIES, OTHER_KEY]; function daysUntilExpiry(dateStr: string): number { const diff = new Date(dateStr).getTime() - Date.now(); return Math.ceil(diff / (1000 * 60 * 60 * 24)); } function DroppableCategory({ groupKey, children }: { groupKey: string; children: React.ReactNode }) { const { setNodeRef, isOver } = useDroppable({ id: groupKey }); return (
{children}
); } function DraggableItemRow({ item, children }: { item: PantryItem; children: React.ReactNode }) { const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id }); const style = transform ? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, zIndex: 10 } : undefined; return (
{children}
); } export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) { const t = useTranslations("pantry"); const tShopping = useTranslations("shoppingLists"); const tCommon = useTranslations("common"); const router = useRouter(); const [items, setItems] = useState(initialItems); const [name, setName] = useState(""); const [quantity, setQuantity] = useState(""); const [unit, setUnit] = useState(""); const [expiresAt, setExpiresAt] = useState(""); const [adding, setAdding] = useState(false); const [confirmId, setConfirmId] = useState(null); const [editingItem, setEditingItem] = useState(null); const [merging, setMerging] = useState(false); const [autoCategorizing, setAutoCategorizing] = useState(false); const [collapsed, setCollapsed] = useState>(() => { const initialByKey = new Map(); for (const item of initialItems) { const key = item.aisle ?? OTHER_KEY; initialByKey.set(key, (initialByKey.get(key) ?? 0) + 1); } return new Set(ALL_CATEGORY_KEYS.filter((key) => !initialByKey.get(key))); }); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); async function mergeDuplicates() { setMerging(true); try { const res = await fetch("/api/v1/pantry/merge-duplicates", { method: "POST" }); if (!res.ok) { toast.error(t("mergeDuplicatesFailed")); return; } const { removed } = await res.json() as { mergedGroups: number; removed: number }; if (removed === 0) toast.success(t("mergeDuplicatesNoneFound")); else toast.success(t("mergeDuplicatesSuccess", { count: removed })); router.refresh(); } finally { setMerging(false); } } async function autoCategorize() { const targets = items.filter((i) => !i.aisle); const updates = targets .map((i) => ({ id: i.id, aisle: guessAisle(i.rawName) })) .filter((u): u is { id: string; aisle: GroceryCategory } => u.aisle !== null); if (updates.length === 0) { toast.error(tShopping("autoCategorizeNoneFound")); return; } setAutoCategorizing(true); try { await Promise.all(updates.map((u) => fetch(`/api/v1/pantry/${u.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ aisle: u.aisle }), }) )); const byId = new Map(updates.map((u) => [u.id, u.aisle])); setItems((prev) => prev.map((i) => (byId.has(i.id) ? { ...i, aisle: byId.get(i.id)! } : i))); setCollapsed((prev) => { const next = new Set(prev); for (const u of updates) next.delete(u.aisle); return next; }); toast.success(tShopping("autoCategorizeSuccess", { count: updates.length })); } finally { setAutoCategorizing(false); } } async function add() { if (!name.trim()) return; setAdding(true); try { const res = await fetch("/api/v1/pantry", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rawName: name.trim(), quantity: quantity.trim() || undefined, unit: unit.trim() || undefined, expiresAt: expiresAt ? new Date(expiresAt).toISOString() : undefined, }), }); if (!res.ok) { toast.error(t("addFailed")); return; } const { id } = await res.json() as { id: string }; setItems((prev) => [ ...prev, { id, rawName: name.trim(), quantity: quantity || null, unit: unit || null, notes: null, aisle: null, expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null }, ]); setCollapsed((prev) => { const next = new Set(prev); next.delete(OTHER_KEY); return next; }); setName(""); setQuantity(""); setUnit(""); setExpiresAt(""); } finally { setAdding(false); } } async function remove(id: string) { const res = await fetch(`/api/v1/pantry/${id}`, { method: "DELETE" }); if (res.ok) setItems((prev) => prev.filter((i) => i.id !== id)); else toast.error(t("removeFailed")); } async function moveToCategory(id: string, newKey: string) { const newAisle = newKey === OTHER_KEY ? null : newKey; setItems((prev) => prev.map((i) => (i.id === id ? { ...i, aisle: newAisle } : i))); const res = await fetch(`/api/v1/pantry/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ aisle: newAisle }), }); if (!res.ok) toast.error(t("editFailed")); } function handleDragEnd(event: DragEndEvent) { const { active, over } = event; if (!over) return; const item = items.find((i) => i.id === active.id); if (!item) return; const currentKey = item.aisle ?? OTHER_KEY; const destKey = String(over.id); if (currentKey === destKey) return; setCollapsed((prev) => { const next = new Set(prev); next.delete(destKey); return next; }); void moveToCategory(item.id, destKey); } function toggleCollapsed(key: string) { setCollapsed((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); } const itemPendingDelete = items.find((i) => i.id === confirmId) ?? null; const sortWithinGroup = (a: PantryItem, b: PantryItem) => { if (a.expiresAt && b.expiresAt) return new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime(); if (a.expiresAt) return -1; if (b.expiresAt) return 1; return a.rawName.localeCompare(b.rawName); }; const grouped = new Map(); for (const key of ALL_CATEGORY_KEYS) grouped.set(key, []); for (const item of items) { const key = item.aisle ?? OTHER_KEY; if (!grouped.has(key)) grouped.set(key, []); grouped.get(key)!.push(item); } for (const group of grouped.values()) group.sort(sortWithinGroup); function categoryLabel(key: string): string { return key === OTHER_KEY ? tShopping("aisleOther") : tShopping(`categories.${key}`); } const sortedGroupKeys = [...grouped.keys()].sort((a, b) => categoryLabel(a).localeCompare(categoryLabel(b))); const uncategorizedCount = items.filter((i) => !i.aisle).length; return (
{/* Add form */}
setName(e.target.value)} placeholder={t("itemNamePlaceholder")} className="flex-1 min-w-[160px]" onKeyDown={(e) => e.key === "Enter" && add()} /> setQuantity(e.target.value)} placeholder={t("qtyPlaceholder")} className="w-20" /> setUnit(e.target.value)} placeholder={t("unitPlaceholder")} className="w-20" /> setExpiresAt(e.target.value)} className="w-36" /> router.refresh()} /> {uncategorizedCount > 0 && ( )}
{/* Item list */} {items.length === 0 ? ( ) : (
{sortedGroupKeys.map((key) => { const groupItems = grouped.get(key)!; const isCollapsed = collapsed.has(key); return (
{!isCollapsed && ( {groupItems.length === 0 ? (

{t("categoryEmptyDropHint")}

) : (
{groupItems.map((item) => { const days = item.expiresAt ? daysUntilExpiry(item.expiresAt) : null; const expiring = days !== null && days <= 3; const expired = days !== null && days < 0; return (
{item.rawName} {hasQuantity(item.quantity) && ( {formatQuantity(parseFloat(item.quantity!))}{item.unit ? ` ${item.unit}` : ""} )} {expired && {t("expired")}} {expiring && !expired && {t("expiresInDays", { days })}}
{item.expiresAt && !expired && !expiring && (

{t("expiresOn", { date: new Date(item.expiresAt).toLocaleDateString() })}

)} {item.notes &&

{item.notes}

}
); })}
)}
)}
); })}
)} !open && setConfirmId(null)}> {t("removeConfirmTitle")} {itemPendingDelete ? t("removeConfirmDescription", { name: itemPendingDelete.rawName }) : ""} {tCommon("cancel")} { if (confirmId) void remove(confirmId); setConfirmId(null); }} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > {tCommon("delete")} {editingItem && ( !open && setEditingItem(null)} onSaved={(updated) => { setItems((prev) => prev.map((i) => (i.id === updated.id ? updated : i))); setEditingItem(null); }} /> )}
); }