diff --git a/src/app/(dashboard)/kitchen/pantry/page.tsx b/src/app/(dashboard)/kitchen/pantry/page.tsx
index 71d21c3..50df554 100644
--- a/src/app/(dashboard)/kitchen/pantry/page.tsx
+++ b/src/app/(dashboard)/kitchen/pantry/page.tsx
@@ -15,6 +15,7 @@ export default async function PantryPage() {
pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
location: { select: { id: true, name: true } },
plant: { select: { id: true, commonName: true, variety: true } },
+ labels: { select: { label: { select: { name: true } } } },
},
}),
db.location.findMany({
@@ -50,6 +51,7 @@ export default async function PantryPage() {
locationName: i.location?.name ?? null,
plantId: i.plant?.id ?? null,
plantName: i.plant ? `${i.plant.commonName}${i.plant.variety ? ` (${i.plant.variety})` : ""}` : null,
+ tags: i.labels.map((l: { label: { name: string } }) => l.label.name),
}));
return ;
diff --git a/src/app/api/pantry/[id]/route.ts b/src/app/api/pantry/[id]/route.ts
index 9c595de..c56ab36 100644
--- a/src/app/api/pantry/[id]/route.ts
+++ b/src/app/api/pantry/[id]/route.ts
@@ -4,6 +4,29 @@ import { ItemSource } from "@prisma/client";
import { db } from "@/lib/db";
import { requireAuth, isAdmin } 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,
+ pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
+ location: { select: { id: true, name: true } },
+ plant: { select: { id: true, commonName: true, variety: true } },
+ labels: { select: { label: { select: { id: true, name: true } } } },
+} as const;
+
+async function syncTags(itemId: string, tags: string[]) {
+ const names = Array.from(new Set(tags.map(t => t.trim()).filter(Boolean)));
+ const labels = await Promise.all(
+ names.map(name => db.label.upsert({ where: { name }, update: {}, create: { name }, select: { id: true } }))
+ );
+ await db.labelOnItem.deleteMany({ where: { itemId } });
+ if (labels.length) {
+ await db.labelOnItem.createMany({
+ data: labels.map(l => ({ itemId, labelId: l.id })),
+ skipDuplicates: true,
+ });
+ }
+}
+
const updateSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().nullable().optional(),
@@ -20,6 +43,7 @@ const updateSchema = z.object({
pricePaid: z.number().nullable().optional(),
priceUnit: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
+ tags: z.array(z.string()).optional(),
});
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
@@ -27,7 +51,7 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
const session = await requireAuth();
const data = updateSchema.parse(await req.json());
- const item = await db.item.update({
+ await db.item.update({
where: { id: params.id },
data: {
...(data.name !== undefined && { name: data.name }),
@@ -45,20 +69,22 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
...(data.priceUnit !== undefined && { priceUnit: data.priceUnit ?? 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,
- pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
- location: { select: { id: true, name: true } },
- plant: { select: { id: true, commonName: true, variety: true } },
- },
});
+ if (data.tags !== undefined) await syncTags(params.id, data.tags);
+
+ const item = await db.item.findUniqueOrThrow({ where: { id: params.id }, select: PANTRY_SELECT });
+
await db.auditEvent.create({
data: { action: "pantry.update", entityId: item.id, actorId: session.user.id, payload: { name: item.name } },
});
- return NextResponse.json(item);
+ return NextResponse.json({
+ ...item,
+ quantity: Number(item.quantity),
+ pricePaid: item.pricePaid != null ? Number(item.pricePaid) : null,
+ tags: item.labels.map(l => l.label.name),
+ });
} 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 });
diff --git a/src/app/api/pantry/catalog/route.ts b/src/app/api/pantry/catalog/route.ts
index 3e0399c..e0427ee 100644
--- a/src/app/api/pantry/catalog/route.ts
+++ b/src/app/api/pantry/catalog/route.ts
@@ -32,12 +32,15 @@ export async function GET(req: Request) {
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"],
- });
+ // Distinct categories/subcategories and all label names for autocomplete.
+ const [allCats, allLabels] = await Promise.all([
+ db.item.findMany({
+ where: { kind: "CONSUMABLE", pantryCategory: { not: null } },
+ select: { pantryCategory: true, pantrySubcategory: true },
+ distinct: ["pantryCategory", "pantrySubcategory"],
+ }),
+ db.label.findMany({ select: { name: true }, orderBy: { name: "asc" } }),
+ ]);
const categories = Array.from(new Set(allCats.map(c => c.pantryCategory).filter(Boolean))) as string[];
const subcategories = allCats
@@ -60,5 +63,6 @@ export async function GET(req: Request) {
})),
categories,
subcategories,
+ labels: allLabels.map(l => l.name),
});
}
diff --git a/src/app/api/pantry/route.ts b/src/app/api/pantry/route.ts
index 93456c4..54a60df 100644
--- a/src/app/api/pantry/route.ts
+++ b/src/app/api/pantry/route.ts
@@ -10,6 +10,7 @@ const PANTRY_SELECT = {
pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
location: { select: { id: true, name: true } },
plant: { select: { id: true, commonName: true, variety: true } },
+ labels: { select: { label: { select: { id: true, name: true } } } },
} as const;
function serialize(i: any) {
@@ -27,9 +28,25 @@ function serialize(i: any) {
locationName: i.location?.name ?? null,
plantId: i.plant?.id ?? null,
plantName: i.plant ? `${i.plant.commonName}${i.plant.variety ? ` (${i.plant.variety})` : ""}` : null,
+ tags: (i.labels ?? []).map((l: any) => l.label.name) as string[],
};
}
+async function syncTags(itemId: string, tags: string[]) {
+ const names = Array.from(new Set(tags.map(t => t.trim()).filter(Boolean)));
+ // Upsert each label, then replace all LabelOnItem rows for this item.
+ const labels = await Promise.all(
+ names.map(name => db.label.upsert({ where: { name }, update: {}, create: { name }, select: { id: true } }))
+ );
+ await db.labelOnItem.deleteMany({ where: { itemId } });
+ if (labels.length) {
+ await db.labelOnItem.createMany({
+ data: labels.map(l => ({ itemId, labelId: l.id })),
+ skipDuplicates: true,
+ });
+ }
+}
+
export async function GET() {
await requireAuth();
const items = await db.item.findMany({
@@ -56,6 +73,7 @@ const createSchema = z.object({
pricePaid: z.number().nullable().optional(),
priceUnit: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
+ tags: z.array(z.string()).optional(),
});
export async function POST(req: Request) {
@@ -84,11 +102,15 @@ export async function POST(req: Request) {
select: PANTRY_SELECT,
});
+ if (data.tags?.length) await syncTags(item.id, data.tags);
+
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 });
+ // Re-fetch with labels
+ const full = await db.item.findUniqueOrThrow({ where: { id: item.id }, select: PANTRY_SELECT });
+ return NextResponse.json(serialize(full), { 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 });
diff --git a/src/components/pantry/pantry-client.tsx b/src/components/pantry/pantry-client.tsx
index 0daec43..79000d6 100644
--- a/src/components/pantry/pantry-client.tsx
+++ b/src/components/pantry/pantry-client.tsx
@@ -24,6 +24,7 @@ export type PantryItem = {
pricePaid: number | null;
priceUnit: string | null;
notes: string | null;
+ tags: string[];
};
export type LocationOption = { id: string; name: string; path: string | null };
@@ -71,6 +72,13 @@ function ItemRow({ item, locations, plants, onRefresh }: {
{item.storedAt && Stored {new Date(item.storedAt).toLocaleDateString()} }
{item.bestBefore && }
+ {item.tags?.length > 0 && (
+
+ {item.tags.map(tag => (
+ {tag}
+ ))}
+
+ )}
{item.notes && {item.notes}
}
diff --git a/src/components/pantry/pantry-item-dialog.tsx b/src/components/pantry/pantry-item-dialog.tsx
index a1bd898..781f8eb 100644
--- a/src/components/pantry/pantry-item-dialog.tsx
+++ b/src/components/pantry/pantry-item-dialog.tsx
@@ -40,6 +40,7 @@ type CatalogData = {
suggestions: Suggestion[];
categories: string[];
subcategories: { category: string; subcategory: string }[];
+ labels: string[];
};
type Form = {
@@ -74,7 +75,12 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
const [nameSuggestions, setNameSuggestions] = useState([]);
const [showDropdown, setShowDropdown] = useState(false);
const [autofillBanner, setAutofillBanner] = useState(null);
+ const [tags, setTags] = useState(item?.tags ?? []);
+ const [tagInput, setTagInput] = useState("");
+ const [tagSuggestions, setTagSuggestions] = useState([]);
+ const [showTagDropdown, setShowTagDropdown] = useState(false);
const nameRef = useRef(null);
+ const tagInputRef = useRef(null);
const debounceRef = useRef>();
const blankForm = (): Form => ({
@@ -100,6 +106,35 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
setForm(f => ({ ...f, [key]: val }));
}
+ function addTag(name: string) {
+ const t = name.trim();
+ if (!t || tags.includes(t)) return;
+ setTags(prev => [...prev, t]);
+ setTagInput("");
+ setShowTagDropdown(false);
+ }
+
+ function removeTag(name: string) {
+ setTags(prev => prev.filter(t => t !== name));
+ }
+
+ function handleTagKeyDown(e: React.KeyboardEvent) {
+ if ((e.key === "Enter" || e.key === ",") && tagInput.trim()) {
+ e.preventDefault();
+ addTag(tagInput);
+ } else if (e.key === "Backspace" && !tagInput && tags.length) {
+ removeTag(tags[tags.length - 1]);
+ }
+ }
+
+ // Reset tag state when dialog opens/closes
+ useEffect(() => {
+ if (open) {
+ setTags(item?.tags ?? []);
+ setTagInput("");
+ }
+ }, [open]);
+
// Load catalog on open (for name autocomplete suggestions)
useEffect(() => {
if (!open) return;
@@ -109,6 +144,15 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
.catch(() => {});
}, [open]);
+ // Tag autocomplete from catalog labels
+ useEffect(() => {
+ const q = tagInput.trim().toLowerCase();
+ if (!q || !catalog?.labels) { setTagSuggestions([]); setShowTagDropdown(false); return; }
+ const matches = catalog.labels.filter(l => l.toLowerCase().includes(q) && !tags.includes(l));
+ setTagSuggestions(matches.slice(0, 6));
+ setShowTagDropdown(matches.length > 0 || q.length > 0);
+ }, [tagInput, catalog, tags]);
+
// Debounced name search for autocomplete
function handleNameChange(val: string) {
set("name", val);
@@ -186,6 +230,7 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
priceUnit: form.priceUnit || null,
notes: form.notes.trim() || null,
kind: "CONSUMABLE",
+ tags,
};
const res = await fetch(item ? `/api/pantry/${item.id}` : "/api/pantry", {
method: item ? "PATCH" : "POST",
@@ -196,6 +241,8 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
setOpen(false);
setForm(blankForm());
setAutofillBanner(null);
+ setTags([]);
+ setTagInput("");
onSuccess();
} catch (err) {
setError(String(err).replace("Error: ", ""));
@@ -383,6 +430,49 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
+ {/* Tags — multi-label, e.g. "Pre-cooked" + "Poultry" */}
+
+
Tags
+
tagInputRef.current?.focus()}>
+ {tags.map(tag => (
+
+ {tag}
+ { e.stopPropagation(); removeTag(tag); }} className="hover:text-destructive">
+
+
+
+ ))}
+
+
setTagInput(e.target.value)}
+ onKeyDown={handleTagKeyDown}
+ onBlur={() => setTimeout(() => setShowTagDropdown(false), 150)}
+ />
+ {showTagDropdown && (
+
+ {tagSuggestions.map(s => (
+
addTag(s)}
+ className="w-full text-left px-3 py-2 text-sm hover:bg-accent">
+ {s}
+
+ ))}
+ {tagInput.trim() && !tagSuggestions.some(s => s.toLowerCase() === tagInput.trim().toLowerCase()) && (
+
addTag(tagInput)}
+ className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center gap-2 text-primary border-t">
+ Add “{tagInput.trim()}”
+
+ )}
+
+ )}
+
+
+
Press Enter or comma to add. Tag things multiple ways, like “Pre-cooked” and “Poultry”.
+
+
{error && {error}
}
diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts
index 9914388..f113092 100644
--- a/src/lib/changelog.ts
+++ b/src/lib/changelog.ts
@@ -8,6 +8,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
+ {
+ version: "0.20.0",
+ date: "2026-06-29",
+ changes: [
+ "Pantry: items can now have multiple tags — type them in the new Tags field when adding or editing an item (press Enter or comma to add each one, like \"Pre-cooked\" and \"Poultry\"). Tags show up as small chips on each item row. Previously-used tags autocomplete as you type.",
+ ],
+ },
{
version: "0.19.2",
date: "2026-06-29",
diff --git a/src/lib/version.ts b/src/lib/version.ts
index 7abd81f..43eea40 100644
--- a/src/lib/version.ts
+++ b/src/lib/version.ts
@@ -1,2 +1,2 @@
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
-export const APP_VERSION = "0.19.2";
+export const APP_VERSION = "0.20.0";