Files
Moonbase/src/components/pantry/pantry-client.tsx
Bonna Moon c449b0c2ae v0.18.1 patch — Pantry: location dropdown shows Kitchen only
Filter locations to Kitchen and its children (fridge, freezer, pantry
shelves, etc.). Display the full path so "Kitchen › Freezer" shows
instead of just "Freezer".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 17:49:37 -05:00

203 lines
8.6 KiB
TypeScript

"use client";
import { useState } from "react";
import { AlertTriangle, ChevronDown, ChevronRight, UtensilsCrossed } from "lucide-react";
import { PantryItemDialog } from "./pantry-item-dialog";
import { UseItemDialog } from "./use-item-dialog";
export type PantryItem = {
id: string;
name: string;
description: string | null;
quantity: number;
unit: string | null;
packageSize: string | null;
storedAt: Date | null;
bestBefore: Date | null;
source: string | null;
locationId: string | null;
locationName: string | null;
plantId: string | null;
plantName: string | null;
pantryCategory: string | null;
pantrySubcategory: string | null;
pricePaid: number | null;
priceUnit: string | null;
notes: string | null;
};
export type LocationOption = { id: string; name: string; path: string | null };
export type PlantOption = { id: string; commonName: string; variety: string | null };
const SOURCE_LABELS: Record<string, string> = {
GARDEN: "Our garden", HOMEMADE: "Homemade", STORE: "Store",
FARMERS_MARKET: "Farmers market", GIFTED: "Gifted", OTHER: "Other",
};
function daysUntil(date: Date) {
return Math.ceil((new Date(date).getTime() - Date.now()) / 86_400_000);
}
function ExpiryBadge({ date }: { date: Date }) {
const days = daysUntil(date);
if (days < 0) return <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 px-1.5 py-0.5 rounded">Expired</span>;
if (days <= 7) return <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 px-1.5 py-0.5 rounded">Expires in {days}d</span>;
if (days <= 30) return <span className="text-xs font-medium text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded">Expires in {days}d</span>;
return <span className="text-xs text-muted-foreground">{new Date(date).toLocaleDateString()}</span>;
}
function ItemRow({ item, locations, plants, onRefresh }: {
item: PantryItem; locations: LocationOption[]; plants: PlantOption[]; onRefresh: () => void;
}) {
const priceStr = item.pricePaid != null
? `$${item.pricePaid.toFixed(2)}${item.priceUnit && item.priceUnit !== "package" ? `/${item.priceUnit}` : ""}`
: null;
return (
<div className="flex items-center gap-3 px-4 py-3 border-b last:border-0 hover:bg-muted/30 transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{item.name}</span>
{item.description && <span className="text-xs text-muted-foreground">{item.description}</span>}
{item.source && <span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">{SOURCE_LABELS[item.source] ?? item.source}</span>}
{item.plantName && <span className="text-xs text-green-700 bg-green-50 border border-green-200 px-1.5 py-0.5 rounded">🌱 {item.plantName}</span>}
</div>
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
<span className="text-sm text-muted-foreground">
{item.quantity} {item.unit ?? ""}
</span>
{priceStr && <span className="text-xs text-muted-foreground">{priceStr}</span>}
{item.locationName && <span className="text-xs text-muted-foreground">· {item.locationName}</span>}
{item.storedAt && <span className="text-xs text-muted-foreground">Stored {new Date(item.storedAt).toLocaleDateString()}</span>}
{item.bestBefore && <ExpiryBadge date={item.bestBefore} />}
</div>
{item.notes && <p className="text-xs text-muted-foreground mt-0.5 italic">{item.notes}</p>}
</div>
<div className="flex items-center gap-1 shrink-0">
<UseItemDialog item={item} onSuccess={onRefresh} />
<PantryItemDialog item={item} locations={locations} plants={plants} onSuccess={onRefresh} />
</div>
</div>
);
}
function SubcategoryGroup({ name, items, locations, plants, onRefresh }: {
name: string; items: PantryItem[]; locations: LocationOption[]; plants: PlantOption[]; onRefresh: () => void;
}) {
const [open, setOpen] = useState(true);
return (
<div>
<button onClick={() => setOpen(o => !o)} className="w-full flex items-center gap-2 px-4 py-2 hover:bg-muted/20 transition-colors">
{open ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
<span className="text-sm font-medium text-muted-foreground">{name}</span>
<span className="text-xs text-muted-foreground">({items.length})</span>
</button>
{open && items.map(item => (
<ItemRow key={item.id} item={item} locations={locations} plants={plants} onRefresh={onRefresh} />
))}
</div>
);
}
function CategoryGroup({ name, items, locations, plants, onRefresh }: {
name: string; items: PantryItem[]; locations: LocationOption[]; plants: PlantOption[]; onRefresh: () => void;
}) {
const [open, setOpen] = useState(true);
const expiring = items.filter(i => i.bestBefore && daysUntil(i.bestBefore) <= 30);
// Group by subcategory
const subcats = items.reduce<Record<string, PantryItem[]>>((acc, item) => {
const key = item.pantrySubcategory ?? "—";
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
}, {});
const hasSubcats = Object.keys(subcats).length > 1 || !subcats["—"];
return (
<div className="rounded-lg border bg-card overflow-hidden">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-2">
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
<span className="font-semibold">{name}</span>
<span className="text-sm text-muted-foreground">{items.length} {items.length === 1 ? "item" : "items"}</span>
</div>
{expiring.length > 0 && (
<span className="flex items-center gap-1 text-xs text-amber-600">
<AlertTriangle className="h-3 w-3" />
{expiring.length} expiring soon
</span>
)}
</button>
{open && (
<div className="border-t">
{hasSubcats
? Object.entries(subcats).sort(([a], [b]) => a.localeCompare(b)).map(([sub, subItems]) => (
<SubcategoryGroup key={sub} name={sub === "—" ? "Other" : sub} items={subItems} locations={locations} plants={plants} onRefresh={onRefresh} />
))
: items.map(item => (
<ItemRow key={item.id} item={item} locations={locations} plants={plants} onRefresh={onRefresh} />
))
}
</div>
)}
</div>
);
}
export function PantryClient({ initialItems, locations, plants }: {
initialItems: PantryItem[]; locations: LocationOption[]; plants: PlantOption[];
}) {
const [items, setItems] = useState(initialItems);
async function refresh() {
const res = await fetch("/api/pantry");
if (res.ok) setItems(await res.json());
}
const grouped = items.reduce<Record<string, PantryItem[]>>((acc, item) => {
const key = item.pantryCategory ?? "Uncategorized";
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
}, {});
const expiringCount = items.filter(i => i.bestBefore && daysUntil(i.bestBefore) <= 30).length;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold">Pantry & Freezer</h1>
{expiringCount > 0 && (
<p className="text-sm text-amber-600 mt-0.5 flex items-center gap-1">
<AlertTriangle className="h-3.5 w-3.5" />
{expiringCount} {expiringCount === 1 ? "item" : "items"} expiring within 30 days
</p>
)}
</div>
<PantryItemDialog locations={locations} plants={plants} onSuccess={refresh} />
</div>
{items.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<UtensilsCrossed className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">Nothing in the pantry yet add your first item.</p>
</div>
) : (
<div className="space-y-4">
{Object.entries(grouped)
.sort(([a], [b]) => a === "Uncategorized" ? 1 : b === "Uncategorized" ? -1 : a.localeCompare(b))
.map(([name, catItems]) => (
<CategoryGroup key={name} name={name} items={catItems} locations={locations} plants={plants} onRefresh={refresh} />
))}
</div>
)}
</div>
);
}