v0.6.0 — Kitchen: Pantry & Freezer inventory
This commit is contained in:
15
prisma/migrations/20260629000001_v0_19_pantry/migration.sql
Normal file
15
prisma/migrations/20260629000001_v0_19_pantry/migration.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- v0.19 Pantry: add storedAt, source enum, and plantId to Item
|
||||
|
||||
CREATE TYPE "ItemSource" AS ENUM ('GARDEN', 'HOMEMADE', 'STORE', 'FARMERS_MARKET', 'GIFTED', 'OTHER');
|
||||
|
||||
ALTER TABLE "Item"
|
||||
ADD COLUMN "storedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "source" "ItemSource",
|
||||
ADD COLUMN "plantId" TEXT;
|
||||
|
||||
ALTER TABLE "Item"
|
||||
ADD CONSTRAINT "Item_plantId_fkey"
|
||||
FOREIGN KEY ("plantId") REFERENCES "Plant"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX "Item_plantId_idx" ON "Item"("plantId");
|
||||
@@ -165,6 +165,7 @@ model Plant {
|
||||
updatedAt DateTime @updatedAt
|
||||
logs PlantLog[]
|
||||
tasks Task[]
|
||||
pantryItems Item[]
|
||||
|
||||
@@index([category])
|
||||
@@index([zone])
|
||||
@@ -299,7 +300,16 @@ model PlantLog {
|
||||
|
||||
enum ItemKind {
|
||||
DURABLE // tools, gear, equipment — "where is it?"
|
||||
CONSUMABLE // food, feed, supplies — "do I have it?" (Pantry phase)
|
||||
CONSUMABLE // food, feed, supplies — "do I have it?"
|
||||
}
|
||||
|
||||
enum ItemSource {
|
||||
GARDEN // grown/harvested here
|
||||
HOMEMADE // made/preserved ourselves
|
||||
STORE // grocery or big-box store
|
||||
FARMERS_MARKET // local market
|
||||
GIFTED // given by someone
|
||||
OTHER
|
||||
}
|
||||
|
||||
model Item {
|
||||
@@ -338,10 +348,14 @@ model Item {
|
||||
// Custom fields — flexible key/value (e.g. VIN, "Oil Weight": "5W-30")
|
||||
customFields Json?
|
||||
|
||||
// Consumable attributes (Pantry phase)
|
||||
// Consumable attributes
|
||||
barcode String?
|
||||
bestBefore DateTime?
|
||||
storedAt DateTime? // date frozen / preserved / stored
|
||||
bestBefore DateTime? // expiry / best-by date
|
||||
minStock Decimal? @db.Decimal(10, 2)
|
||||
source ItemSource? // where it came from
|
||||
plantId String? // link to a garden plant (harvest source)
|
||||
plant Plant? @relation(fields: [plantId], references: [id], onDelete: SetNull)
|
||||
|
||||
// Shared
|
||||
qrSlug String? @unique // short slug for a printed QR label
|
||||
@@ -362,6 +376,7 @@ model Item {
|
||||
@@index([active])
|
||||
@@index([name])
|
||||
@@index([barcode])
|
||||
@@index([plantId])
|
||||
}
|
||||
|
||||
model Label {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getSession } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Leaf, CalendarDays, Sprout } from "lucide-react";
|
||||
import { Leaf, Sprout, AlertTriangle, UtensilsCrossed } from "lucide-react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
export const metadata = { title: "Dashboard" };
|
||||
@@ -10,13 +10,21 @@ export const metadata = { title: "Dashboard" };
|
||||
export default async function DashboardPage() {
|
||||
const session = await getSession();
|
||||
|
||||
const [plantCount, recentLogs] = await Promise.all([
|
||||
const thirtyDaysFromNow = new Date(Date.now() + 30 * 86_400_000);
|
||||
|
||||
const [plantCount, recentLogs, expiringItems] = await Promise.all([
|
||||
db.plant.count({ where: { active: true } }),
|
||||
db.plantLog.findMany({
|
||||
orderBy: { date: "desc" },
|
||||
take: 5,
|
||||
include: { plant: { select: { commonName: true, variety: true } } },
|
||||
}),
|
||||
db.item.findMany({
|
||||
where: { kind: "CONSUMABLE", active: true, bestBefore: { lte: thirtyDaysFromNow } },
|
||||
orderBy: { bestBefore: "asc" },
|
||||
select: { id: true, name: true, bestBefore: true, quantity: true, unit: true },
|
||||
take: 5,
|
||||
}),
|
||||
]);
|
||||
|
||||
const firstName = session?.user.name.split(" ")[0] ?? "there";
|
||||
@@ -42,6 +50,34 @@ export default async function DashboardPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/kitchen/pantry">
|
||||
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<UtensilsCrossed className="h-4 w-4 text-amber-600" />
|
||||
Kitchen
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{expiringItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Nothing expiring soon</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium text-amber-700">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
{expiringItems.length} expiring soon
|
||||
</p>
|
||||
{expiringItems.slice(0, 3).map(i => (
|
||||
<p key={i.id} className="text-xs text-muted-foreground truncate">
|
||||
{i.name} · {new Date(i.bestBefore!).toLocaleDateString()}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{recentLogs.length > 0 && (
|
||||
|
||||
41
src/app/(dashboard)/kitchen/pantry/page.tsx
Normal file
41
src/app/(dashboard)/kitchen/pantry/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { PantryClient } from "@/components/pantry/pantry-client";
|
||||
|
||||
export const metadata = { title: "Pantry & Freezer" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PantryPage() {
|
||||
const [items, locations, plants] = await Promise.all([
|
||||
db.item.findMany({
|
||||
where: { kind: "CONSUMABLE", active: true },
|
||||
orderBy: [{ location: { name: "asc" } }, { name: "asc" }],
|
||||
select: {
|
||||
id: true, name: true, description: true, quantity: true, unit: true,
|
||||
storedAt: true, bestBefore: true, source: true, plantId: true, notes: true,
|
||||
location: { select: { id: true, name: true } },
|
||||
plant: { select: { id: true, commonName: true, variety: true } },
|
||||
},
|
||||
}),
|
||||
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||
db.plant.findMany({ where: { active: true }, orderBy: { commonName: "asc" }, select: { id: true, commonName: true, variety: true } }),
|
||||
]);
|
||||
|
||||
const serialized = items.map(i => ({
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
description: i.description,
|
||||
quantity: Number(i.quantity),
|
||||
unit: i.unit,
|
||||
packageSize: i.description,
|
||||
storedAt: i.storedAt,
|
||||
bestBefore: i.bestBefore,
|
||||
source: i.source,
|
||||
notes: i.notes,
|
||||
locationId: i.location?.id ?? null,
|
||||
locationName: i.location?.name ?? null,
|
||||
plantId: i.plant?.id ?? null,
|
||||
plantName: i.plant ? `${i.plant.commonName}${i.plant.variety ? ` (${i.plant.variety})` : ""}` : null,
|
||||
}));
|
||||
|
||||
return <PantryClient initialItems={serialized} locations={locations} plants={plants} />;
|
||||
}
|
||||
75
src/app/api/pantry/[id]/route.ts
Normal file
75
src/app/api/pantry/[id]/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { ItemSource } from "@prisma/client";
|
||||
import { db } from "@/lib/db";
|
||||
import { requireAuth, isAdmin } from "@/lib/auth";
|
||||
|
||||
const updateSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
quantity: z.number().min(0).optional(),
|
||||
unit: z.string().nullable().optional(),
|
||||
packageSize: z.string().nullable().optional(),
|
||||
locationId: z.string().nullable().optional(),
|
||||
storedAt: z.string().nullable().optional(),
|
||||
bestBefore: z.string().nullable().optional(),
|
||||
source: z.nativeEnum(ItemSource).nullable().optional(),
|
||||
plantId: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
const data = updateSchema.parse(await req.json());
|
||||
|
||||
const item = await db.item.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
...(data.name !== undefined && { name: data.name }),
|
||||
...(data.description !== undefined && { description: data.description ?? data.packageSize ?? null }),
|
||||
...(data.quantity !== undefined && { quantity: data.quantity }),
|
||||
...(data.unit !== undefined && { unit: data.unit ?? null }),
|
||||
...(data.locationId !== undefined && { locationId: data.locationId ?? null }),
|
||||
...(data.storedAt !== undefined && { storedAt: data.storedAt ? new Date(data.storedAt) : null }),
|
||||
...(data.bestBefore !== undefined && { bestBefore: data.bestBefore ? new Date(data.bestBefore) : null }),
|
||||
...(data.source !== undefined && { source: data.source ?? null }),
|
||||
...(data.plantId !== undefined && { plantId: data.plantId ?? null }),
|
||||
...(data.notes !== undefined && { notes: data.notes ?? null }),
|
||||
},
|
||||
select: {
|
||||
id: true, name: true, description: true, quantity: true, unit: true,
|
||||
storedAt: true, bestBefore: true, source: true, plantId: true, notes: true,
|
||||
location: { select: { id: true, name: true } },
|
||||
plant: { select: { id: true, commonName: true, variety: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditEvent.create({
|
||||
data: { action: "pantry.update", entityId: item.id, actorId: session.user.id, payload: { name: item.name } },
|
||||
});
|
||||
|
||||
return NextResponse.json(item);
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
console.error(err);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
if (!isAdmin(session.user.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
await db.item.update({ where: { id: params.id }, data: { active: false } });
|
||||
await db.auditEvent.create({
|
||||
data: { action: "pantry.delete", entityId: params.id, actorId: session.user.id },
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
console.error(err);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
48
src/app/api/pantry/[id]/use/route.ts
Normal file
48
src/app/api/pantry/[id]/use/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
const schema = z.object({
|
||||
amount: z.number().positive(),
|
||||
notes: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
const { amount, notes } = schema.parse(await req.json());
|
||||
|
||||
const item = await db.item.findUnique({ where: { id: params.id } });
|
||||
if (!item || !item.active) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const newQty = Math.max(0, Number(item.quantity) - amount);
|
||||
|
||||
await db.item.update({ where: { id: params.id }, data: { quantity: newQty } });
|
||||
|
||||
await db.itemLog.create({
|
||||
data: {
|
||||
itemId: params.id,
|
||||
type: "NOTE",
|
||||
notes: `Used ${amount} ${item.unit ?? ""}${notes ? ` — ${notes}` : ""}`.trim(),
|
||||
actorId: session.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
await db.auditEvent.create({
|
||||
data: {
|
||||
action: "pantry.use",
|
||||
entityId: params.id,
|
||||
actorId: session.user.id,
|
||||
payload: { amount, remaining: newQty, notes },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, remaining: newQty });
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
console.error(err);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
85
src/app/api/pantry/route.ts
Normal file
85
src/app/api/pantry/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { ItemSource } from "@prisma/client";
|
||||
import { db } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
const PANTRY_SELECT = {
|
||||
id: true, name: true, description: true, quantity: true, unit: true,
|
||||
storedAt: true, bestBefore: true, source: true, plantId: true, notes: true,
|
||||
location: { select: { id: true, name: true } },
|
||||
plant: { select: { id: true, commonName: true, variety: true } },
|
||||
} as const;
|
||||
|
||||
function serialize(i: any) {
|
||||
return {
|
||||
id: i.id, name: i.name, description: i.description,
|
||||
quantity: Number(i.quantity), unit: i.unit,
|
||||
packageSize: i.description,
|
||||
storedAt: i.storedAt, bestBefore: i.bestBefore,
|
||||
source: i.source, notes: i.notes,
|
||||
locationId: i.location?.id ?? null,
|
||||
locationName: i.location?.name ?? null,
|
||||
plantId: i.plant?.id ?? null,
|
||||
plantName: i.plant ? `${i.plant.commonName}${i.plant.variety ? ` (${i.plant.variety})` : ""}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
await requireAuth();
|
||||
const items = await db.item.findMany({
|
||||
where: { kind: "CONSUMABLE", active: true },
|
||||
orderBy: [{ location: { name: "asc" } }, { name: "asc" }],
|
||||
select: PANTRY_SELECT,
|
||||
});
|
||||
return NextResponse.json(items.map(serialize));
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().nullable().optional(),
|
||||
quantity: z.number().min(0).default(1),
|
||||
unit: z.string().nullable().optional(),
|
||||
packageSize: z.string().nullable().optional(),
|
||||
locationId: z.string().nullable().optional(),
|
||||
storedAt: z.string().nullable().optional(),
|
||||
bestBefore: z.string().nullable().optional(),
|
||||
source: z.nativeEnum(ItemSource).nullable().optional(),
|
||||
plantId: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const session = await requireAuth();
|
||||
const data = createSchema.parse(await req.json());
|
||||
|
||||
const item = await db.item.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description ?? data.packageSize ?? null,
|
||||
kind: "CONSUMABLE",
|
||||
quantity: data.quantity,
|
||||
unit: data.unit ?? null,
|
||||
locationId: data.locationId ?? null,
|
||||
storedAt: data.storedAt ? new Date(data.storedAt) : null,
|
||||
bestBefore: data.bestBefore ? new Date(data.bestBefore) : null,
|
||||
source: data.source ?? null,
|
||||
plantId: data.plantId ?? null,
|
||||
notes: data.notes ?? null,
|
||||
},
|
||||
select: PANTRY_SELECT,
|
||||
});
|
||||
|
||||
await db.auditEvent.create({
|
||||
data: { action: "pantry.create", entityId: item.id, actorId: session.user.id, payload: { name: item.name } },
|
||||
});
|
||||
|
||||
return NextResponse.json(serialize(item), { status: 201 });
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
console.error(err);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
LayoutDashboard, Leaf, BookOpen, Package, PawPrint, MapPin, Bell,
|
||||
Lightbulb, KeyRound, HardDrive, RefreshCw, type LucideIcon,
|
||||
Lightbulb, KeyRound, HardDrive, RefreshCw, UtensilsCrossed, type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export type NavItem = { label: string; href: string; icon: LucideIcon };
|
||||
export type NavItem = { label: string; href: string; icon: LucideIcon; section?: string };
|
||||
|
||||
export const navItems: NavItem[] = [
|
||||
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
||||
@@ -15,6 +15,10 @@ export const navItems: NavItem[] = [
|
||||
{ label: "Reminders", href: "/tasks", icon: Bell },
|
||||
];
|
||||
|
||||
export const kitchenItems: NavItem[] = [
|
||||
{ label: "Pantry & Freezer", href: "/kitchen/pantry", icon: UtensilsCrossed },
|
||||
];
|
||||
|
||||
export const settingsItems: NavItem[] = [
|
||||
{ label: "Suggestions", href: "/suggestions", icon: Lightbulb },
|
||||
{ label: "API tokens", href: "/settings/tokens", icon: KeyRound },
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Leaf } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { navItems, settingsItems, isNavActive } from "./nav-items";
|
||||
import { navItems, kitchenItems, settingsItems, isNavActive } from "./nav-items";
|
||||
|
||||
export function Sidebar() {
|
||||
const path = usePathname();
|
||||
@@ -39,6 +39,24 @@ export function Sidebar() {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="px-2 py-2 border-t border-[hsl(var(--canopy-border))] space-y-0.5">
|
||||
<p className="px-3 pt-1 pb-0.5 text-[10px] uppercase tracking-widest text-[hsl(var(--canopy-foreground))]/40">Kitchen</p>
|
||||
{kitchenItems.map(({ label, href, icon: Icon }) => {
|
||||
const active = path.startsWith(href);
|
||||
return (
|
||||
<Link key={href} href={href} className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors",
|
||||
active
|
||||
? "bg-[hsl(var(--canopy-accent))] text-[hsl(var(--canopy-accent-foreground))]"
|
||||
: "hover:bg-[hsl(var(--canopy-accent))] hover:text-[hsl(var(--canopy-accent-foreground))] text-[hsl(var(--canopy-foreground))]/80"
|
||||
)}>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-2 border-t border-[hsl(var(--canopy-border))] space-y-0.5">
|
||||
{settingsItems.map(({ label, href, icon: Icon }) => {
|
||||
const active = path.startsWith(href);
|
||||
|
||||
170
src/components/pantry/pantry-client.tsx
Normal file
170
src/components/pantry/pantry-client.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, Plus, ChevronDown, ChevronRight, UtensilsCrossed } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export type LocationOption = { id: string; name: string };
|
||||
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 <= 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;
|
||||
}) {
|
||||
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 ?? ""}
|
||||
{item.packageSize ? ` · ${item.packageSize}` : ""}
|
||||
</span>
|
||||
{item.storedAt && <span className="text-xs text-muted-foreground">Stored {new Date(item.storedAt).toLocaleDateString()}</span>}
|
||||
{item.bestBefore && <ExpiryBadge date={item.bestBefore} />}
|
||||
</div>
|
||||
</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 LocationGroup({ 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);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors rounded-t-lg"
|
||||
>
|
||||
<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">
|
||||
{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.locationName ?? "No location";
|
||||
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.localeCompare(b)).map(([name, groupItems]) => (
|
||||
<LocationGroup key={name} name={name} items={groupItems} locations={locations} plants={plants} onRefresh={refresh} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
src/components/pantry/pantry-item-dialog.tsx
Normal file
186
src/components/pantry/pantry-item-dialog.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, Pencil } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { PantryItem, LocationOption, PlantOption } from "./pantry-client";
|
||||
|
||||
const SOURCES = [
|
||||
{ value: "GARDEN", label: "Our garden" },
|
||||
{ value: "HOMEMADE", label: "Homemade" },
|
||||
{ value: "STORE", label: "Store" },
|
||||
{ value: "FARMERS_MARKET", label: "Farmers market" },
|
||||
{ value: "GIFTED", label: "Gifted" },
|
||||
{ value: "OTHER", label: "Other" },
|
||||
];
|
||||
|
||||
const COMMON_UNITS = ["lbs", "oz", "g", "bags", "jars", "quart jars", "pint jars", "gallon bags", "quart bags", "containers", "cans", "bottles", "bunches", "loaves", "portions"];
|
||||
|
||||
type Props = {
|
||||
item?: PantryItem;
|
||||
locations: LocationOption[];
|
||||
plants: PlantOption[];
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
export function PantryItemDialog({ item, locations, plants, onSuccess }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: item?.name ?? "",
|
||||
description: item?.description ?? "",
|
||||
quantity: item?.quantity?.toString() ?? "1",
|
||||
unit: item?.unit ?? "",
|
||||
packageSize: item?.packageSize ?? "",
|
||||
locationId: item?.locationId ?? "",
|
||||
storedAt: item?.storedAt ? new Date(item.storedAt).toISOString().slice(0, 10) : "",
|
||||
bestBefore: item?.bestBefore ? new Date(item.bestBefore).toISOString().slice(0, 10) : "",
|
||||
source: item?.source ?? "",
|
||||
plantId: item?.plantId ?? "",
|
||||
notes: item?.notes ?? "",
|
||||
});
|
||||
|
||||
function set(key: string, val: string) {
|
||||
setForm(f => ({ ...f, [key]: val }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const body = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
quantity: parseFloat(form.quantity) || 1,
|
||||
unit: form.unit.trim() || null,
|
||||
packageSize: form.packageSize.trim() || null,
|
||||
locationId: form.locationId || null,
|
||||
storedAt: form.storedAt || null,
|
||||
bestBefore: form.bestBefore || null,
|
||||
source: form.source || null,
|
||||
plantId: form.plantId || null,
|
||||
notes: form.notes.trim() || null,
|
||||
kind: "CONSUMABLE",
|
||||
};
|
||||
const res = await fetch(item ? `/api/pantry/${item.id}` : "/api/pantry", {
|
||||
method: item ? "PATCH" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error ?? "Failed");
|
||||
setOpen(false);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(String(err).replace("Error: ", ""));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const inputCls = "w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
|
||||
|
||||
return (
|
||||
<>
|
||||
{item ? (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setOpen(true)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" /> Add item
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{item ? "Edit item" : "Add pantry item"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 py-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Name *</Label>
|
||||
<input className={inputCls} value={form.name} onChange={e => set("name", e.target.value)} required placeholder="e.g. Blueberries, Chicken broth, Sourdough" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Description / package type</Label>
|
||||
<input className={inputCls} value={form.description} onChange={e => set("description", e.target.value)} placeholder="e.g. quart freezer bag, 1-gallon zip bag" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Quantity *</Label>
|
||||
<input className={inputCls} type="number" min="0" step="0.25" value={form.quantity} onChange={e => set("quantity", e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Unit</Label>
|
||||
<input className={inputCls} list="unit-suggestions" value={form.unit} onChange={e => set("unit", e.target.value)} placeholder="bags, lbs, jars…" />
|
||||
<datalist id="unit-suggestions">
|
||||
{COMMON_UNITS.map(u => <option key={u} value={u} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Location</Label>
|
||||
<select className={inputCls} value={form.locationId} onChange={e => set("locationId", e.target.value)}>
|
||||
<option value="">— none —</option>
|
||||
{locations.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Date stored / frozen</Label>
|
||||
<input className={inputCls} type="date" value={form.storedAt} onChange={e => set("storedAt", e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Best by / expires</Label>
|
||||
<input className={inputCls} type="date" value={form.bestBefore} onChange={e => set("bestBefore", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Source</Label>
|
||||
<select className={inputCls} value={form.source} onChange={e => set("source", e.target.value)}>
|
||||
<option value="">— unknown —</option>
|
||||
{SOURCES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{form.source === "GARDEN" && (
|
||||
<div className="space-y-1">
|
||||
<Label>Plant (optional)</Label>
|
||||
<select className={inputCls} value={form.plantId} onChange={e => set("plantId", e.target.value)}>
|
||||
<option value="">— select a plant —</option>
|
||||
{plants.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.commonName}{p.variety ? ` (${p.variety})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Notes</Label>
|
||||
<textarea className={inputCls} rows={2} value={form.notes} onChange={e => set("notes", e.target.value)} placeholder="Any notes…" />
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button type="submit" disabled={saving}>{saving ? "Saving…" : item ? "Save" : "Add item"}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
83
src/components/pantry/use-item-dialog.tsx
Normal file
83
src/components/pantry/use-item-dialog.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Minus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { PantryItem } from "./pantry-client";
|
||||
|
||||
export function UseItemDialog({ item, onSuccess }: { item: PantryItem; onSuccess: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [amount, setAmount] = useState("1");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/pantry/${item.id}/use`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ amount: parseFloat(amount), notes: notes.trim() || null }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error ?? "Failed");
|
||||
setOpen(false);
|
||||
setAmount("1");
|
||||
setNotes("");
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(String(err).replace("Error: ", ""));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const inputCls = "w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setOpen(true)} title="Use some">
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Use {item.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 py-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Currently: <strong>{item.quantity} {item.unit ?? ""}</strong>
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<Label>How much are you using? ({item.unit ?? "units"})</Label>
|
||||
<input
|
||||
className={inputCls}
|
||||
type="number"
|
||||
min="0.25"
|
||||
max={item.quantity}
|
||||
step="0.25"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Notes (optional)</Label>
|
||||
<input className={inputCls} value={notes} onChange={e => setNotes(e.target.value)} placeholder="e.g. used in chicken soup" />
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button type="submit" disabled={saving}>{saving ? "Saving…" : "Use"}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user