v0.6.1 — Pantry: categories, price tracking, and autocomplete
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
-- v0.20: pantry categories + price tracking
|
||||||
|
|
||||||
|
ALTER TABLE "Item"
|
||||||
|
ADD COLUMN "pantryCategory" TEXT,
|
||||||
|
ADD COLUMN "pantrySubcategory" TEXT,
|
||||||
|
ADD COLUMN "pricePaid" DECIMAL(10,2),
|
||||||
|
ADD COLUMN "priceUnit" TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX "Item_pantryCategory_idx" ON "Item"("pantryCategory");
|
||||||
@@ -356,6 +356,10 @@ model Item {
|
|||||||
source ItemSource? // where it came from
|
source ItemSource? // where it came from
|
||||||
plantId String? // link to a garden plant (harvest source)
|
plantId String? // link to a garden plant (harvest source)
|
||||||
plant Plant? @relation(fields: [plantId], references: [id], onDelete: SetNull)
|
plant Plant? @relation(fields: [plantId], references: [id], onDelete: SetNull)
|
||||||
|
pantryCategory String? // e.g. "Meat", "Produce", "Dairy"
|
||||||
|
pantrySubcategory String? // e.g. "Beef", "Poultry", "Berries"
|
||||||
|
pricePaid Decimal? @db.Decimal(10, 2) // how much was paid
|
||||||
|
priceUnit String? // "package", "lb", "oz", "kg", etc.
|
||||||
|
|
||||||
// Shared
|
// Shared
|
||||||
qrSlug String? @unique // short slug for a printed QR label
|
qrSlug String? @unique // short slug for a printed QR label
|
||||||
@@ -377,6 +381,7 @@ model Item {
|
|||||||
@@index([name])
|
@@index([name])
|
||||||
@@index([barcode])
|
@@index([barcode])
|
||||||
@@index([plantId])
|
@@index([plantId])
|
||||||
|
@@index([pantryCategory])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Label {
|
model Label {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default async function PantryPage() {
|
|||||||
select: {
|
select: {
|
||||||
id: true, name: true, description: true, quantity: true, unit: true,
|
id: true, name: true, description: true, quantity: true, unit: true,
|
||||||
storedAt: true, bestBefore: true, source: true, plantId: true, notes: true,
|
storedAt: true, bestBefore: true, source: true, plantId: true, notes: true,
|
||||||
|
pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
|
||||||
location: { select: { id: true, name: true } },
|
location: { select: { id: true, name: true } },
|
||||||
plant: { select: { id: true, commonName: true, variety: true } },
|
plant: { select: { id: true, commonName: true, variety: true } },
|
||||||
},
|
},
|
||||||
@@ -31,6 +32,10 @@ export default async function PantryPage() {
|
|||||||
bestBefore: i.bestBefore,
|
bestBefore: i.bestBefore,
|
||||||
source: i.source,
|
source: i.source,
|
||||||
notes: i.notes,
|
notes: i.notes,
|
||||||
|
pantryCategory: i.pantryCategory,
|
||||||
|
pantrySubcategory: i.pantrySubcategory,
|
||||||
|
pricePaid: i.pricePaid != null ? Number(i.pricePaid) : null,
|
||||||
|
priceUnit: i.priceUnit,
|
||||||
locationId: i.location?.id ?? null,
|
locationId: i.location?.id ?? null,
|
||||||
locationName: i.location?.name ?? null,
|
locationName: i.location?.name ?? null,
|
||||||
plantId: i.plant?.id ?? null,
|
plantId: i.plant?.id ?? null,
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ const updateSchema = z.object({
|
|||||||
bestBefore: z.string().nullable().optional(),
|
bestBefore: z.string().nullable().optional(),
|
||||||
source: z.nativeEnum(ItemSource).nullable().optional(),
|
source: z.nativeEnum(ItemSource).nullable().optional(),
|
||||||
plantId: z.string().nullable().optional(),
|
plantId: z.string().nullable().optional(),
|
||||||
|
pantryCategory: z.string().nullable().optional(),
|
||||||
|
pantrySubcategory: z.string().nullable().optional(),
|
||||||
|
pricePaid: z.number().nullable().optional(),
|
||||||
|
priceUnit: z.string().nullable().optional(),
|
||||||
notes: z.string().nullable().optional(),
|
notes: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,11 +39,16 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
|
|||||||
...(data.bestBefore !== undefined && { bestBefore: data.bestBefore ? new Date(data.bestBefore) : null }),
|
...(data.bestBefore !== undefined && { bestBefore: data.bestBefore ? new Date(data.bestBefore) : null }),
|
||||||
...(data.source !== undefined && { source: data.source ?? null }),
|
...(data.source !== undefined && { source: data.source ?? null }),
|
||||||
...(data.plantId !== undefined && { plantId: data.plantId ?? null }),
|
...(data.plantId !== undefined && { plantId: data.plantId ?? null }),
|
||||||
|
...(data.pantryCategory !== undefined && { pantryCategory: data.pantryCategory ?? null }),
|
||||||
|
...(data.pantrySubcategory !== undefined && { pantrySubcategory: data.pantrySubcategory ?? null }),
|
||||||
|
...(data.pricePaid !== undefined && { pricePaid: data.pricePaid ?? null }),
|
||||||
|
...(data.priceUnit !== undefined && { priceUnit: data.priceUnit ?? null }),
|
||||||
...(data.notes !== undefined && { notes: data.notes ?? null }),
|
...(data.notes !== undefined && { notes: data.notes ?? null }),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true, name: true, description: true, quantity: true, unit: true,
|
id: true, name: true, description: true, quantity: true, unit: true,
|
||||||
storedAt: true, bestBefore: true, source: true, plantId: true, notes: true,
|
storedAt: true, bestBefore: true, source: true, plantId: true, notes: true,
|
||||||
|
pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
|
||||||
location: { select: { id: true, name: true } },
|
location: { select: { id: true, name: true } },
|
||||||
plant: { select: { id: true, commonName: true, variety: true } },
|
plant: { select: { id: true, commonName: true, variety: true } },
|
||||||
},
|
},
|
||||||
|
|||||||
64
src/app/api/pantry/catalog/route.ts
Normal file
64
src/app/api/pantry/catalog/route.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { requireAuth } from "@/lib/auth";
|
||||||
|
|
||||||
|
// Returns distinct pantry item names + their most-recent settings for autocomplete.
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
await requireAuth();
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const q = searchParams.get("q")?.trim().toLowerCase() ?? "";
|
||||||
|
|
||||||
|
// Grab all consumable items (active + inactive so we remember old entries).
|
||||||
|
const items = await db.item.findMany({
|
||||||
|
where: {
|
||||||
|
kind: "CONSUMABLE",
|
||||||
|
...(q ? { name: { contains: q, mode: "insensitive" } } : {}),
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
select: {
|
||||||
|
name: true, description: true, unit: true, source: true,
|
||||||
|
pantryCategory: true, pantrySubcategory: true,
|
||||||
|
priceUnit: true, pricePaid: true,
|
||||||
|
locationId: true, plantId: true,
|
||||||
|
location: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
take: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deduplicate by name (case-insensitive), keep the most-recent record per name.
|
||||||
|
const seen = new Map<string, typeof items[number]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const key = item.name.toLowerCase();
|
||||||
|
if (!seen.has(key)) seen.set(key, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also return distinct categories/subcategories for autocomplete dropdowns.
|
||||||
|
const allCats = await db.item.findMany({
|
||||||
|
where: { kind: "CONSUMABLE", pantryCategory: { not: null } },
|
||||||
|
select: { pantryCategory: true, pantrySubcategory: true },
|
||||||
|
distinct: ["pantryCategory", "pantrySubcategory"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const categories = Array.from(new Set(allCats.map(c => c.pantryCategory).filter(Boolean))) as string[];
|
||||||
|
const subcategories = allCats
|
||||||
|
.filter(c => c.pantrySubcategory)
|
||||||
|
.map(c => ({ category: c.pantryCategory!, subcategory: c.pantrySubcategory! }));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
suggestions: Array.from(seen.values()).map(i => ({
|
||||||
|
name: i.name,
|
||||||
|
description: i.description,
|
||||||
|
unit: i.unit,
|
||||||
|
source: i.source,
|
||||||
|
pantryCategory: i.pantryCategory,
|
||||||
|
pantrySubcategory: i.pantrySubcategory,
|
||||||
|
priceUnit: i.priceUnit,
|
||||||
|
pricePaid: i.pricePaid != null ? Number(i.pricePaid) : null,
|
||||||
|
locationId: i.locationId,
|
||||||
|
locationName: i.location?.name ?? null,
|
||||||
|
plantId: i.plantId,
|
||||||
|
})),
|
||||||
|
categories,
|
||||||
|
subcategories,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { requireAuth } from "@/lib/auth";
|
|||||||
const PANTRY_SELECT = {
|
const PANTRY_SELECT = {
|
||||||
id: true, name: true, description: true, quantity: true, unit: true,
|
id: true, name: true, description: true, quantity: true, unit: true,
|
||||||
storedAt: true, bestBefore: true, source: true, plantId: true, notes: true,
|
storedAt: true, bestBefore: true, source: true, plantId: true, notes: true,
|
||||||
|
pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
|
||||||
location: { select: { id: true, name: true } },
|
location: { select: { id: true, name: true } },
|
||||||
plant: { select: { id: true, commonName: true, variety: true } },
|
plant: { select: { id: true, commonName: true, variety: true } },
|
||||||
} as const;
|
} as const;
|
||||||
@@ -18,6 +19,10 @@ function serialize(i: any) {
|
|||||||
packageSize: i.description,
|
packageSize: i.description,
|
||||||
storedAt: i.storedAt, bestBefore: i.bestBefore,
|
storedAt: i.storedAt, bestBefore: i.bestBefore,
|
||||||
source: i.source, notes: i.notes,
|
source: i.source, notes: i.notes,
|
||||||
|
pantryCategory: i.pantryCategory,
|
||||||
|
pantrySubcategory: i.pantrySubcategory,
|
||||||
|
pricePaid: i.pricePaid != null ? Number(i.pricePaid) : null,
|
||||||
|
priceUnit: i.priceUnit,
|
||||||
locationId: i.location?.id ?? null,
|
locationId: i.location?.id ?? null,
|
||||||
locationName: i.location?.name ?? null,
|
locationName: i.location?.name ?? null,
|
||||||
plantId: i.plant?.id ?? null,
|
plantId: i.plant?.id ?? null,
|
||||||
@@ -46,6 +51,10 @@ const createSchema = z.object({
|
|||||||
bestBefore: z.string().nullable().optional(),
|
bestBefore: z.string().nullable().optional(),
|
||||||
source: z.nativeEnum(ItemSource).nullable().optional(),
|
source: z.nativeEnum(ItemSource).nullable().optional(),
|
||||||
plantId: z.string().nullable().optional(),
|
plantId: z.string().nullable().optional(),
|
||||||
|
pantryCategory: z.string().nullable().optional(),
|
||||||
|
pantrySubcategory: z.string().nullable().optional(),
|
||||||
|
pricePaid: z.number().nullable().optional(),
|
||||||
|
priceUnit: z.string().nullable().optional(),
|
||||||
notes: z.string().nullable().optional(),
|
notes: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,6 +75,10 @@ export async function POST(req: Request) {
|
|||||||
bestBefore: data.bestBefore ? new Date(data.bestBefore) : null,
|
bestBefore: data.bestBefore ? new Date(data.bestBefore) : null,
|
||||||
source: data.source ?? null,
|
source: data.source ?? null,
|
||||||
plantId: data.plantId ?? null,
|
plantId: data.plantId ?? null,
|
||||||
|
pantryCategory: data.pantryCategory ?? null,
|
||||||
|
pantrySubcategory: data.pantrySubcategory ?? null,
|
||||||
|
pricePaid: data.pricePaid ?? null,
|
||||||
|
priceUnit: data.priceUnit ?? null,
|
||||||
notes: data.notes ?? null,
|
notes: data.notes ?? null,
|
||||||
},
|
},
|
||||||
select: PANTRY_SELECT,
|
select: PANTRY_SELECT,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { AlertTriangle, Plus, ChevronDown, ChevronRight, UtensilsCrossed } from "lucide-react";
|
import { AlertTriangle, ChevronDown, ChevronRight, UtensilsCrossed } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { PantryItemDialog } from "./pantry-item-dialog";
|
import { PantryItemDialog } from "./pantry-item-dialog";
|
||||||
import { UseItemDialog } from "./use-item-dialog";
|
import { UseItemDialog } from "./use-item-dialog";
|
||||||
|
|
||||||
@@ -20,6 +19,10 @@ export type PantryItem = {
|
|||||||
locationName: string | null;
|
locationName: string | null;
|
||||||
plantId: string | null;
|
plantId: string | null;
|
||||||
plantName: string | null;
|
plantName: string | null;
|
||||||
|
pantryCategory: string | null;
|
||||||
|
pantrySubcategory: string | null;
|
||||||
|
pricePaid: number | null;
|
||||||
|
priceUnit: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,12 +30,8 @@ export type LocationOption = { id: string; name: string };
|
|||||||
export type PlantOption = { id: string; commonName: string; variety: string | null };
|
export type PlantOption = { id: string; commonName: string; variety: string | null };
|
||||||
|
|
||||||
const SOURCE_LABELS: Record<string, string> = {
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
GARDEN: "Our garden",
|
GARDEN: "Our garden", HOMEMADE: "Homemade", STORE: "Store",
|
||||||
HOMEMADE: "Homemade",
|
FARMERS_MARKET: "Farmers market", GIFTED: "Gifted", OTHER: "Other",
|
||||||
STORE: "Store",
|
|
||||||
FARMERS_MARKET: "Farmers market",
|
|
||||||
GIFTED: "Gifted",
|
|
||||||
OTHER: "Other",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function daysUntil(date: Date) {
|
function daysUntil(date: Date) {
|
||||||
@@ -42,16 +41,18 @@ function daysUntil(date: Date) {
|
|||||||
function ExpiryBadge({ date }: { date: Date }) {
|
function ExpiryBadge({ date }: { date: Date }) {
|
||||||
const days = daysUntil(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 < 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>;
|
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>;
|
return <span className="text-xs text-muted-foreground">{new Date(date).toLocaleDateString()}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItemRow({ item, locations, plants, onRefresh }: {
|
function ItemRow({ item, locations, plants, onRefresh }: {
|
||||||
item: PantryItem;
|
item: PantryItem; locations: LocationOption[]; plants: PlantOption[]; onRefresh: () => void;
|
||||||
locations: LocationOption[];
|
|
||||||
plants: PlantOption[];
|
|
||||||
onRefresh: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
|
const priceStr = item.pricePaid != null
|
||||||
|
? `$${item.pricePaid.toFixed(2)}${item.priceUnit && item.priceUnit !== "package" ? `/${item.priceUnit}` : ""}`
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
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 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-1 min-w-0">
|
||||||
@@ -64,11 +65,13 @@ function ItemRow({ item, locations, plants, onRefresh }: {
|
|||||||
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
|
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{item.quantity} {item.unit ?? ""}
|
{item.quantity} {item.unit ?? ""}
|
||||||
{item.packageSize ? ` · ${item.packageSize}` : ""}
|
|
||||||
</span>
|
</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.storedAt && <span className="text-xs text-muted-foreground">Stored {new Date(item.storedAt).toLocaleDateString()}</span>}
|
||||||
{item.bestBefore && <ExpiryBadge date={item.bestBefore} />}
|
{item.bestBefore && <ExpiryBadge date={item.bestBefore} />}
|
||||||
</div>
|
</div>
|
||||||
|
{item.notes && <p className="text-xs text-muted-foreground mt-0.5 italic">{item.notes}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<UseItemDialog item={item} onSuccess={onRefresh} />
|
<UseItemDialog item={item} onSuccess={onRefresh} />
|
||||||
@@ -78,21 +81,44 @@ function ItemRow({ item, locations, plants, onRefresh }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LocationGroup({ name, items, locations, plants, onRefresh }: {
|
function SubcategoryGroup({ name, items, locations, plants, onRefresh }: {
|
||||||
name: string;
|
name: string; items: PantryItem[]; locations: LocationOption[]; plants: PlantOption[]; onRefresh: () => void;
|
||||||
items: PantryItem[];
|
}) {
|
||||||
locations: LocationOption[];
|
const [open, setOpen] = useState(true);
|
||||||
plants: PlantOption[];
|
return (
|
||||||
onRefresh: () => void;
|
<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 [open, setOpen] = useState(true);
|
||||||
const expiring = items.filter(i => i.bestBefore && daysUntil(i.bestBefore) <= 30);
|
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 (
|
return (
|
||||||
<div className="rounded-lg border bg-card">
|
<div className="rounded-lg border bg-card overflow-hidden">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(o => !o)}
|
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"
|
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">
|
<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" />}
|
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||||
@@ -106,11 +132,17 @@ function LocationGroup({ name, items, locations, plants, onRefresh }: {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="border-t">
|
<div className="border-t">
|
||||||
{items.map(item => (
|
{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} />
|
<ItemRow key={item.id} item={item} locations={locations} plants={plants} onRefresh={onRefresh} />
|
||||||
))}
|
))
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -118,9 +150,7 @@ function LocationGroup({ name, items, locations, plants, onRefresh }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PantryClient({ initialItems, locations, plants }: {
|
export function PantryClient({ initialItems, locations, plants }: {
|
||||||
initialItems: PantryItem[];
|
initialItems: PantryItem[]; locations: LocationOption[]; plants: PlantOption[];
|
||||||
locations: LocationOption[];
|
|
||||||
plants: PlantOption[];
|
|
||||||
}) {
|
}) {
|
||||||
const [items, setItems] = useState(initialItems);
|
const [items, setItems] = useState(initialItems);
|
||||||
|
|
||||||
@@ -130,7 +160,7 @@ export function PantryClient({ initialItems, locations, plants }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const grouped = items.reduce<Record<string, PantryItem[]>>((acc, item) => {
|
const grouped = items.reduce<Record<string, PantryItem[]>>((acc, item) => {
|
||||||
const key = item.locationName ?? "No location";
|
const key = item.pantryCategory ?? "Uncategorized";
|
||||||
if (!acc[key]) acc[key] = [];
|
if (!acc[key]) acc[key] = [];
|
||||||
acc[key].push(item);
|
acc[key].push(item);
|
||||||
return acc;
|
return acc;
|
||||||
@@ -160,8 +190,10 @@ export function PantryClient({ initialItems, locations, plants }: {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([name, groupItems]) => (
|
{Object.entries(grouped)
|
||||||
<LocationGroup key={name} name={name} items={groupItems} locations={locations} plants={plants} onRefresh={refresh} />
|
.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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Plus, Pencil } from "lucide-react";
|
import { Plus, Pencil, Sparkles, X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -16,7 +16,55 @@ const SOURCES = [
|
|||||||
{ value: "OTHER", label: "Other" },
|
{ 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"];
|
const COMMON_UNITS = ["lbs", "oz", "g", "kg", "bags", "jars", "quart jars", "pint jars", "gallon bags", "quart bags", "containers", "cans", "bottles", "bunches", "loaves", "portions", "each"];
|
||||||
|
const PRICE_UNITS = ["package", "lb", "oz", "kg", "each", "dozen", "bunch"];
|
||||||
|
|
||||||
|
const DEFAULT_CATEGORIES = ["Meat", "Produce", "Dairy & Eggs", "Baked Goods", "Soups & Broths", "Sauces & Condiments", "Grains & Legumes", "Herbs & Spices", "Prepared Meals", "Fruit", "Seafood", "Other"];
|
||||||
|
const DEFAULT_SUBCATEGORIES: Record<string, string[]> = {
|
||||||
|
"Meat": ["Beef", "Poultry", "Pork", "Lamb", "Game", "Sausage", "Other"],
|
||||||
|
"Produce": ["Berries", "Vegetables", "Leafy Greens", "Root Vegetables", "Corn", "Peppers", "Tomatoes", "Other"],
|
||||||
|
"Dairy & Eggs": ["Butter", "Cheese", "Milk", "Cream", "Eggs", "Other"],
|
||||||
|
"Baked Goods": ["Bread", "Muffins", "Cookies", "Cake", "Pie", "Crackers", "Other"],
|
||||||
|
"Fruit": ["Berries", "Stone Fruit", "Apples & Pears", "Citrus", "Tropical", "Other"],
|
||||||
|
"Seafood": ["Fish", "Shrimp", "Shellfish", "Other"],
|
||||||
|
};
|
||||||
|
|
||||||
|
type Suggestion = {
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
unit: string | null;
|
||||||
|
source: string | null;
|
||||||
|
pantryCategory: string | null;
|
||||||
|
pantrySubcategory: string | null;
|
||||||
|
priceUnit: string | null;
|
||||||
|
pricePaid: number | null;
|
||||||
|
locationId: string | null;
|
||||||
|
locationName: string | null;
|
||||||
|
plantId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CatalogData = {
|
||||||
|
suggestions: Suggestion[];
|
||||||
|
categories: string[];
|
||||||
|
subcategories: { category: string; subcategory: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Form = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
quantity: string;
|
||||||
|
unit: string;
|
||||||
|
locationId: string;
|
||||||
|
storedAt: string;
|
||||||
|
bestBefore: string;
|
||||||
|
source: string;
|
||||||
|
plantId: string;
|
||||||
|
pantryCategory: string;
|
||||||
|
pantrySubcategory: string;
|
||||||
|
pricePaid: string;
|
||||||
|
priceUnit: string;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item?: PantryItem;
|
item?: PantryItem;
|
||||||
@@ -29,25 +77,92 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [catalog, setCatalog] = useState<CatalogData | null>(null);
|
||||||
|
const [nameSuggestions, setNameSuggestions] = useState<Suggestion[]>([]);
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
|
const [autofillBanner, setAutofillBanner] = useState<Suggestion | null>(null);
|
||||||
|
const nameRef = useRef<HTMLInputElement>(null);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const blankForm = (): Form => ({
|
||||||
name: item?.name ?? "",
|
name: item?.name ?? "",
|
||||||
description: item?.description ?? "",
|
description: item?.description ?? "",
|
||||||
quantity: item?.quantity?.toString() ?? "1",
|
quantity: item?.quantity?.toString() ?? "1",
|
||||||
unit: item?.unit ?? "",
|
unit: item?.unit ?? "",
|
||||||
packageSize: item?.packageSize ?? "",
|
|
||||||
locationId: item?.locationId ?? "",
|
locationId: item?.locationId ?? "",
|
||||||
storedAt: item?.storedAt ? new Date(item.storedAt).toISOString().slice(0, 10) : "",
|
storedAt: item?.storedAt ? new Date(item.storedAt).toISOString().slice(0, 10) : "",
|
||||||
bestBefore: item?.bestBefore ? new Date(item.bestBefore).toISOString().slice(0, 10) : "",
|
bestBefore: item?.bestBefore ? new Date(item.bestBefore).toISOString().slice(0, 10) : "",
|
||||||
source: item?.source ?? "",
|
source: item?.source ?? "",
|
||||||
plantId: item?.plantId ?? "",
|
plantId: item?.plantId ?? "",
|
||||||
|
pantryCategory: (item as any)?.pantryCategory ?? "",
|
||||||
|
pantrySubcategory: (item as any)?.pantrySubcategory ?? "",
|
||||||
|
pricePaid: (item as any)?.pricePaid?.toString() ?? "",
|
||||||
|
priceUnit: (item as any)?.priceUnit ?? "package",
|
||||||
notes: item?.notes ?? "",
|
notes: item?.notes ?? "",
|
||||||
});
|
});
|
||||||
|
|
||||||
function set(key: string, val: string) {
|
const [form, setForm] = useState<Form>(blankForm);
|
||||||
|
|
||||||
|
function set(key: keyof Form, val: string) {
|
||||||
setForm(f => ({ ...f, [key]: val }));
|
setForm(f => ({ ...f, [key]: val }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load catalog on open (for category/subcategory autocomplete)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
fetch("/api/pantry/catalog")
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(setCatalog)
|
||||||
|
.catch(() => {});
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Debounced name search for autocomplete
|
||||||
|
function handleNameChange(val: string) {
|
||||||
|
set("name", val);
|
||||||
|
setAutofillBanner(null);
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
if (val.length < 2) { setNameSuggestions([]); setShowDropdown(false); return; }
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
const res = await fetch(`/api/pantry/catalog?q=${encodeURIComponent(val)}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data: CatalogData = await res.json();
|
||||||
|
setNameSuggestions(data.suggestions.slice(0, 6));
|
||||||
|
setShowDropdown(data.suggestions.length > 0);
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAutofill(s: Suggestion) {
|
||||||
|
setForm(f => ({
|
||||||
|
...f,
|
||||||
|
name: s.name,
|
||||||
|
description: s.description ?? f.description,
|
||||||
|
unit: s.unit ?? f.unit,
|
||||||
|
source: s.source ?? f.source,
|
||||||
|
pantryCategory: s.pantryCategory ?? f.pantryCategory,
|
||||||
|
pantrySubcategory: s.pantrySubcategory ?? f.pantrySubcategory,
|
||||||
|
priceUnit: s.priceUnit ?? f.priceUnit,
|
||||||
|
pricePaid: s.pricePaid != null ? s.pricePaid.toString() : f.pricePaid,
|
||||||
|
locationId: s.locationId ?? f.locationId,
|
||||||
|
plantId: s.plantId ?? f.plantId,
|
||||||
|
}));
|
||||||
|
setShowDropdown(false);
|
||||||
|
setAutofillBanner(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSuggestion(s: Suggestion) {
|
||||||
|
setShowDropdown(false);
|
||||||
|
setAutofillBanner(s);
|
||||||
|
set("name", s.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subcategory options: merge defaults + previously used
|
||||||
|
const allCategories = Array.from(new Set([...DEFAULT_CATEGORIES, ...(catalog?.categories ?? [])])).sort();
|
||||||
|
const subcatDefaults = DEFAULT_SUBCATEGORIES[form.pantryCategory] ?? [];
|
||||||
|
const subcatFromCatalog = (catalog?.subcategories ?? [])
|
||||||
|
.filter(s => s.category === form.pantryCategory)
|
||||||
|
.map(s => s.subcategory);
|
||||||
|
const allSubcategories = Array.from(new Set([...subcatDefaults, ...subcatFromCatalog])).sort();
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -58,12 +173,15 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
|
|||||||
description: form.description.trim() || null,
|
description: form.description.trim() || null,
|
||||||
quantity: parseFloat(form.quantity) || 1,
|
quantity: parseFloat(form.quantity) || 1,
|
||||||
unit: form.unit.trim() || null,
|
unit: form.unit.trim() || null,
|
||||||
packageSize: form.packageSize.trim() || null,
|
|
||||||
locationId: form.locationId || null,
|
locationId: form.locationId || null,
|
||||||
storedAt: form.storedAt || null,
|
storedAt: form.storedAt || null,
|
||||||
bestBefore: form.bestBefore || null,
|
bestBefore: form.bestBefore || null,
|
||||||
source: form.source || null,
|
source: form.source || null,
|
||||||
plantId: form.plantId || null,
|
plantId: form.plantId || null,
|
||||||
|
pantryCategory: form.pantryCategory.trim() || null,
|
||||||
|
pantrySubcategory: form.pantrySubcategory.trim() || null,
|
||||||
|
pricePaid: form.pricePaid ? parseFloat(form.pricePaid) : null,
|
||||||
|
priceUnit: form.priceUnit || null,
|
||||||
notes: form.notes.trim() || null,
|
notes: form.notes.trim() || null,
|
||||||
kind: "CONSUMABLE",
|
kind: "CONSUMABLE",
|
||||||
};
|
};
|
||||||
@@ -74,6 +192,8 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new Error((await res.json()).error ?? "Failed");
|
if (!res.ok) throw new Error((await res.json()).error ?? "Failed");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
setForm(blankForm());
|
||||||
|
setAutofillBanner(null);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err).replace("Error: ", ""));
|
setError(String(err).replace("Error: ", ""));
|
||||||
@@ -96,22 +216,98 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={o => { setOpen(o); if (!o) { setAutofillBanner(null); setShowDropdown(false); } }}>
|
||||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{item ? "Edit item" : "Add pantry item"}</DialogTitle>
|
<DialogTitle>{item ? "Edit item" : "Add pantry item"}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 py-2">
|
<form onSubmit={handleSubmit} className="space-y-4 py-2">
|
||||||
<div className="space-y-1">
|
|
||||||
|
{/* Name with autocomplete */}
|
||||||
|
<div className="space-y-1 relative">
|
||||||
<Label>Name *</Label>
|
<Label>Name *</Label>
|
||||||
<input className={inputCls} value={form.name} onChange={e => set("name", e.target.value)} required placeholder="e.g. Blueberries, Chicken broth, Sourdough" />
|
<input
|
||||||
|
ref={nameRef}
|
||||||
|
className={inputCls}
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => handleNameChange(e.target.value)}
|
||||||
|
onBlur={() => setTimeout(() => setShowDropdown(false), 150)}
|
||||||
|
required
|
||||||
|
placeholder="e.g. Blueberries, Ground beef, Chicken broth"
|
||||||
|
/>
|
||||||
|
{showDropdown && nameSuggestions.length > 0 && (
|
||||||
|
<div className="absolute z-50 left-0 right-0 top-full mt-1 bg-popover border rounded-md shadow-md overflow-hidden">
|
||||||
|
<p className="px-3 py-1.5 text-[10px] text-muted-foreground uppercase tracking-wider">Previously added</p>
|
||||||
|
{nameSuggestions.map(s => (
|
||||||
|
<button
|
||||||
|
key={s.name}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={() => selectSuggestion(s)}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center justify-between gap-2"
|
||||||
|
>
|
||||||
|
<span>{s.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
|
{[s.pantryCategory, s.pantrySubcategory].filter(Boolean).join(" › ")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Autofill banner */}
|
||||||
|
{autofillBanner && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-amber-50 border border-amber-200 px-3 py-2 text-sm">
|
||||||
|
<Sparkles className="h-4 w-4 text-amber-500 shrink-0" />
|
||||||
|
<span className="flex-1 text-amber-800">
|
||||||
|
Fill in previous settings for <strong>{autofillBanner.name}</strong>?
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={() => applyAutofill(autofillBanner)} className="text-amber-700 font-medium hover:underline shrink-0">Apply</button>
|
||||||
|
<button type="button" onClick={() => setAutofillBanner(null)} className="text-amber-400 hover:text-amber-600">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Description / package type</Label>
|
<Label>Category</Label>
|
||||||
<input className={inputCls} value={form.description} onChange={e => set("description", e.target.value)} placeholder="e.g. quart freezer bag, 1-gallon zip bag" />
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
list="cat-list"
|
||||||
|
value={form.pantryCategory}
|
||||||
|
onChange={e => { set("pantryCategory", e.target.value); set("pantrySubcategory", ""); }}
|
||||||
|
placeholder="e.g. Meat"
|
||||||
|
/>
|
||||||
|
<datalist id="cat-list">
|
||||||
|
{allCategories.map(c => <option key={c} value={c} />)}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Subcategory</Label>
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
list="subcat-list"
|
||||||
|
value={form.pantrySubcategory}
|
||||||
|
onChange={e => set("pantrySubcategory", e.target.value)}
|
||||||
|
placeholder="e.g. Beef"
|
||||||
|
disabled={!form.pantryCategory}
|
||||||
|
/>
|
||||||
|
<datalist id="subcat-list">
|
||||||
|
{allSubcategories.map(c => <option key={c} value={c} />)}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Package / container description</Label>
|
||||||
|
<input className={inputCls} value={form.description} onChange={e => set("description", e.target.value)} placeholder="e.g. 1-gallon freezer bag, quart jar, vacuum-sealed" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quantity + unit */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Quantity *</Label>
|
<Label>Quantity *</Label>
|
||||||
@@ -119,13 +315,31 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Unit</Label>
|
<Label>Unit</Label>
|
||||||
<input className={inputCls} list="unit-suggestions" value={form.unit} onChange={e => set("unit", e.target.value)} placeholder="bags, lbs, jars…" />
|
<input className={inputCls} list="unit-list" value={form.unit} onChange={e => set("unit", e.target.value)} placeholder="bags, lbs, jars…" />
|
||||||
<datalist id="unit-suggestions">
|
<datalist id="unit-list">
|
||||||
{COMMON_UNITS.map(u => <option key={u} value={u} />)}
|
{COMMON_UNITS.map(u => <option key={u} value={u} />)}
|
||||||
</datalist>
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Price */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Price paid</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm">$</span>
|
||||||
|
<input className={inputCls + " pl-6"} type="number" min="0" step="0.01" value={form.pricePaid} onChange={e => set("pricePaid", e.target.value)} placeholder="0.00" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Per</Label>
|
||||||
|
<select className={inputCls} value={form.priceUnit} onChange={e => set("priceUnit", e.target.value)}>
|
||||||
|
{PRICE_UNITS.map(u => <option key={u} value={u}>{u}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Location</Label>
|
<Label>Location</Label>
|
||||||
<select className={inputCls} value={form.locationId} onChange={e => set("locationId", e.target.value)}>
|
<select className={inputCls} value={form.locationId} onChange={e => set("locationId", e.target.value)}>
|
||||||
@@ -134,6 +348,7 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Dates */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Date stored / frozen</Label>
|
<Label>Date stored / frozen</Label>
|
||||||
@@ -145,6 +360,7 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Source */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Source</Label>
|
<Label>Source</Label>
|
||||||
<select className={inputCls} value={form.source} onChange={e => set("source", e.target.value)}>
|
<select className={inputCls} value={form.source} onChange={e => set("source", e.target.value)}>
|
||||||
@@ -155,13 +371,11 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
|
|||||||
|
|
||||||
{form.source === "GARDEN" && (
|
{form.source === "GARDEN" && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Plant (optional)</Label>
|
<Label>Plant</Label>
|
||||||
<select className={inputCls} value={form.plantId} onChange={e => set("plantId", e.target.value)}>
|
<select className={inputCls} value={form.plantId} onChange={e => set("plantId", e.target.value)}>
|
||||||
<option value="">— select a plant —</option>
|
<option value="">— select a plant —</option>
|
||||||
{plants.map(p => (
|
{plants.map(p => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>{p.commonName}{p.variety ? ` (${p.variety})` : ""}</option>
|
||||||
{p.commonName}{p.variety ? ` (${p.variety})` : ""}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user