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>
203 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|