diff --git a/prisma/migrations/20260616050040_v0_13_inventory/migration.sql b/prisma/migrations/20260616050040_v0_13_inventory/migration.sql
new file mode 100644
index 0000000..fbcd9a5
--- /dev/null
+++ b/prisma/migrations/20260616050040_v0_13_inventory/migration.sql
@@ -0,0 +1,143 @@
+-- CreateEnum
+CREATE TYPE "ItemKind" AS ENUM ('DURABLE', 'CONSUMABLE');
+
+-- CreateEnum
+CREATE TYPE "ItemLogType" AS ENUM ('NOTE', 'MAINTENANCE', 'MOVED', 'PURCHASED', 'REPAIRED', 'DISPOSED');
+
+-- AlterTable
+ALTER TABLE "PlantGroup" ALTER COLUMN "updatedAt" DROP DEFAULT;
+
+-- AlterTable
+ALTER TABLE "Task" ADD COLUMN "itemId" TEXT;
+
+-- CreateTable
+CREATE TABLE "Item" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "description" TEXT,
+ "kind" "ItemKind" NOT NULL DEFAULT 'DURABLE',
+ "quantity" DECIMAL(10,2) NOT NULL DEFAULT 1,
+ "unit" TEXT,
+ "locationId" TEXT,
+ "parentItemId" TEXT,
+ "value" DECIMAL(10,2),
+ "purchaseDate" TIMESTAMP(3),
+ "warrantyExpiry" TIMESTAMP(3),
+ "serial" TEXT,
+ "modelNumber" TEXT,
+ "brand" TEXT,
+ "barcode" TEXT,
+ "bestBefore" TIMESTAMP(3),
+ "minStock" DECIMAL(10,2),
+ "qrSlug" TEXT,
+ "imageUrl" TEXT,
+ "notes" TEXT,
+ "active" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Item_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Label" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "color" TEXT,
+
+ CONSTRAINT "Label_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "LabelOnItem" (
+ "itemId" TEXT NOT NULL,
+ "labelId" TEXT NOT NULL,
+
+ CONSTRAINT "LabelOnItem_pkey" PRIMARY KEY ("itemId","labelId")
+);
+
+-- CreateTable
+CREATE TABLE "ItemAttachment" (
+ "id" TEXT NOT NULL,
+ "itemId" TEXT NOT NULL,
+ "kind" TEXT NOT NULL,
+ "url" TEXT NOT NULL,
+ "name" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "ItemAttachment_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "ItemLog" (
+ "id" TEXT NOT NULL,
+ "itemId" TEXT NOT NULL,
+ "type" "ItemLogType" NOT NULL,
+ "date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "notes" TEXT,
+ "cost" DECIMAL(10,2),
+ "actorId" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "ItemLog_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Item_qrSlug_key" ON "Item"("qrSlug");
+
+-- CreateIndex
+CREATE INDEX "Item_locationId_idx" ON "Item"("locationId");
+
+-- CreateIndex
+CREATE INDEX "Item_parentItemId_idx" ON "Item"("parentItemId");
+
+-- CreateIndex
+CREATE INDEX "Item_kind_idx" ON "Item"("kind");
+
+-- CreateIndex
+CREATE INDEX "Item_active_idx" ON "Item"("active");
+
+-- CreateIndex
+CREATE INDEX "Item_name_idx" ON "Item"("name");
+
+-- CreateIndex
+CREATE INDEX "Item_barcode_idx" ON "Item"("barcode");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Label_name_key" ON "Label"("name");
+
+-- CreateIndex
+CREATE INDEX "LabelOnItem_labelId_idx" ON "LabelOnItem"("labelId");
+
+-- CreateIndex
+CREATE INDEX "ItemAttachment_itemId_idx" ON "ItemAttachment"("itemId");
+
+-- CreateIndex
+CREATE INDEX "ItemLog_itemId_idx" ON "ItemLog"("itemId");
+
+-- CreateIndex
+CREATE INDEX "ItemLog_date_idx" ON "ItemLog"("date");
+
+-- CreateIndex
+CREATE INDEX "Task_itemId_idx" ON "Task"("itemId");
+
+-- AddForeignKey
+ALTER TABLE "Task" ADD CONSTRAINT "Task_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Item" ADD CONSTRAINT "Item_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Item" ADD CONSTRAINT "Item_parentItemId_fkey" FOREIGN KEY ("parentItemId") REFERENCES "Item"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "LabelOnItem" ADD CONSTRAINT "LabelOnItem_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "LabelOnItem" ADD CONSTRAINT "LabelOnItem_labelId_fkey" FOREIGN KEY ("labelId") REFERENCES "Label"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ItemAttachment" ADD CONSTRAINT "ItemAttachment_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ItemLog" ADD CONSTRAINT "ItemLog_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml
index 99e4f20..fbffa92 100644
--- a/prisma/migrations/migration_lock.toml
+++ b/prisma/migrations/migration_lock.toml
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
-provider = "postgresql"
+provider = "postgresql"
\ No newline at end of file
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 8b94eac..be5eb86 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -100,6 +100,7 @@ model Location {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
plants Plant[]
+ items Item[]
@@index([parentId])
@@index([kind])
@@ -236,6 +237,8 @@ model Task {
category TaskCategory @default(GARDEN)
plantId String?
plant Plant? @relation(fields: [plantId], references: [id])
+ itemId String?
+ item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull)
recurrence RecurrenceType @default(YEARLY)
intervalDays Int? // for CUSTOM: every N days
month Int? // 1–12: for YEARLY, which month
@@ -248,6 +251,7 @@ model Task {
@@index([nextDue])
@@index([plantId])
+ @@index([itemId])
@@index([active])
}
@@ -276,3 +280,118 @@ model PlantLog {
@@index([plantId])
@@index([date])
}
+
+// ---------------------------------------------------------------------------
+// Inventory — things you own. Durable items (tools, gear) and, later,
+// consumables (food, supplies) share this one table, told apart by `kind`.
+// Items nest: an Item lives in a Location OR inside another Item (toolbox →
+// drill). Consumable-only fields (barcode, bestBefore, minStock) are unused
+// by the durable UI but present so Pantry needs no schema change.
+// ---------------------------------------------------------------------------
+
+enum ItemKind {
+ DURABLE // tools, gear, equipment — "where is it?"
+ CONSUMABLE // food, feed, supplies — "do I have it?" (Pantry phase)
+}
+
+model Item {
+ id String @id @default(cuid())
+ name String
+ description String?
+ kind ItemKind @default(DURABLE)
+ quantity Decimal @default(1) @db.Decimal(10, 2)
+ unit String? // "each", "lbs", "bottles"
+
+ // Where it lives — a place, or inside another item (containers are items too).
+ locationId String?
+ location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
+ parentItemId String?
+ parentItem Item? @relation("ItemNesting", fields: [parentItemId], references: [id], onDelete: SetNull)
+ containedItems Item[] @relation("ItemNesting")
+
+ // Durable attributes
+ value Decimal? @db.Decimal(10, 2)
+ purchaseDate DateTime?
+ warrantyExpiry DateTime?
+ serial String?
+ modelNumber String?
+ brand String?
+
+ // Consumable attributes (Pantry phase)
+ barcode String?
+ bestBefore DateTime?
+ minStock Decimal? @db.Decimal(10, 2)
+
+ // Shared
+ qrSlug String? @unique // short slug for a printed QR label
+ imageUrl String?
+ notes String?
+ active Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ labels LabelOnItem[]
+ attachments ItemAttachment[]
+ logs ItemLog[]
+ tasks Task[]
+
+ @@index([locationId])
+ @@index([parentItemId])
+ @@index([kind])
+ @@index([active])
+ @@index([name])
+ @@index([barcode])
+}
+
+model Label {
+ id String @id @default(cuid())
+ name String @unique
+ color String?
+ items LabelOnItem[]
+}
+
+model LabelOnItem {
+ itemId String
+ labelId String
+ item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
+ label Label @relation(fields: [labelId], references: [id], onDelete: Cascade)
+
+ @@id([itemId, labelId])
+ @@index([labelId])
+}
+
+model ItemAttachment {
+ id String @id @default(cuid())
+ itemId String
+ item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
+ kind String // "PHOTO" | "MANUAL" | "RECEIPT"
+ url String
+ name String?
+ createdAt DateTime @default(now())
+
+ @@index([itemId])
+}
+
+enum ItemLogType {
+ NOTE
+ MAINTENANCE
+ MOVED
+ PURCHASED
+ REPAIRED
+ DISPOSED
+}
+
+model ItemLog {
+ id String @id @default(cuid())
+ itemId String
+ item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
+ type ItemLogType
+ date DateTime @default(now())
+ notes String?
+ cost Decimal? @db.Decimal(10, 2)
+ actorId String?
+ createdAt DateTime @default(now())
+
+ @@index([itemId])
+ @@index([date])
+}
diff --git a/src/app/(dashboard)/inventory/[id]/page.tsx b/src/app/(dashboard)/inventory/[id]/page.tsx
new file mode 100644
index 0000000..72bdf01
--- /dev/null
+++ b/src/app/(dashboard)/inventory/[id]/page.tsx
@@ -0,0 +1,192 @@
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { db } from "@/lib/db";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ ChevronLeft, MapPin, Box, Package, CalendarDays, DollarSign, Hash, Tag, FileText, Barcode,
+} from "lucide-react";
+import { formatDate } from "@/lib/utils";
+import { warrantyStatus, WARRANTY_LABELS } from "@/lib/inventory/value";
+import { ITEM_LOG_LABELS, ITEM_LOG_COLORS } from "@/lib/inventory/item-fields";
+import { EditItemButton, type ItemFields } from "@/components/inventory/item-dialog";
+import { DeleteItemButton } from "@/components/inventory/delete-item-button";
+import { AddItemLogButton } from "@/components/inventory/add-item-log-button";
+import { AttachmentUpload } from "@/components/inventory/attachment-upload";
+
+function toDateInput(d: Date | null): string {
+ return d ? new Date(d).toISOString().split("T")[0] : "";
+}
+
+export async function generateMetadata({ params }: { params: { id: string } }) {
+ const item = await db.item.findUnique({ where: { id: params.id }, select: { name: true } });
+ return { title: item?.name ?? "Item" };
+}
+
+export default async function ItemDetailPage({ params }: { params: { id: string } }) {
+ const [item, locations, allItems, labels] = await Promise.all([
+ db.item.findUnique({
+ where: { id: params.id },
+ include: {
+ location: { select: { id: true, name: true } },
+ parentItem: { select: { id: true, name: true } },
+ labels: { include: { label: true } },
+ attachments: { orderBy: { createdAt: "asc" } },
+ logs: { orderBy: { date: "desc" } },
+ containedItems: { where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } },
+ },
+ }),
+ db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
+ db.item.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
+ db.label.findMany({ orderBy: { name: "asc" } }),
+ ]);
+
+ if (!item || !item.active) notFound();
+
+ const wStatus = warrantyStatus(item.warrantyExpiry, new Date());
+ const photos = item.attachments.filter((a) => a.kind === "PHOTO");
+ const docs = item.attachments.filter((a) => a.kind !== "PHOTO");
+
+ const editFields: ItemFields = {
+ id: item.id,
+ name: item.name,
+ description: item.description ?? "",
+ quantity: String(Number(item.quantity)),
+ unit: item.unit ?? "",
+ locationId: item.locationId ?? "",
+ parentItemId: item.parentItemId ?? "",
+ brand: item.brand ?? "",
+ modelNumber: item.modelNumber ?? "",
+ serial: item.serial ?? "",
+ value: item.value != null ? String(Number(item.value)) : "",
+ purchaseDate: toDateInput(item.purchaseDate),
+ warrantyExpiry: toDateInput(item.warrantyExpiry),
+ barcode: item.barcode ?? "",
+ notes: item.notes ?? "",
+ labelIds: item.labels.map((l) => l.labelId),
+ };
+
+ const facts: { icon: React.ReactNode; label: string; value: React.ReactNode }[] = [];
+ if (item.location) facts.push({ icon: , label: "Location", value: item.location.name });
+ if (item.parentItem) facts.push({ icon: , label: "Inside", value: {item.parentItem.name} });
+ if (Number(item.quantity) !== 1) facts.push({ icon: , label: "Quantity", value: `${Number(item.quantity)}${item.unit ? ` ${item.unit}` : ""}` });
+ if (item.value != null) facts.push({ icon: , label: "Value", value: Number(item.value).toLocaleString(undefined, { style: "currency", currency: "USD" }) });
+ if (item.purchaseDate) facts.push({ icon: , label: "Purchased", value: formatDate(item.purchaseDate) });
+ if (item.brand) facts.push({ icon: , label: "Brand", value: item.brand });
+ if (item.modelNumber) facts.push({ icon: , label: "Model", value: item.modelNumber });
+ if (item.serial) facts.push({ icon: , label: "Serial", value: item.serial });
+ if (item.barcode) facts.push({ icon: , label: "Barcode", value: item.barcode });
+
+ return (
+
+
+
+
+
+
+
{item.name}
+ {item.description &&
{item.description}
}
+
+ {wStatus !== "none" && (
+
{WARRANTY_LABELS[wStatus]}
+ )}
+
+
+
+
i.id !== item.id)} labels={labels} />
+
+
+
+
+
+ {item.labels.length > 0 && (
+
+ {item.labels.map((l) => {l.label.name})}
+
+ )}
+
+ {photos.length > 0 && (
+
+ {photos.map((p) => (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ))}
+
+ )}
+
+ {facts.length > 0 && (
+
+
+ {facts.map((f, i) => (
+
+
{f.icon}
+
+
{f.label}
+
{f.value}
+
+
+ ))}
+
+
+ )}
+
+ {item.notes && (
+
{item.notes}
+ )}
+
+ {docs.length > 0 && (
+
+ )}
+
+ {item.containedItems.length > 0 && (
+
+
Contains
+
+ {item.containedItems.map((c) => (
+
+
{c.name}
+
+ ))}
+
+
+ )}
+
+
+
History
+ {item.logs.length === 0 ? (
+
+ No history yet — log maintenance, a repair, or a move above.
+
+ ) : (
+
+ {item.logs.map((log) => (
+
+
+
+
+ {ITEM_LOG_LABELS[log.type]}
+
+ {log.cost != null && · {Number(log.cost).toLocaleString(undefined, { style: "currency", currency: "USD" })}}
+ {formatDate(log.date)}
+
+ {log.notes &&
{log.notes}
}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/(dashboard)/inventory/page.tsx b/src/app/(dashboard)/inventory/page.tsx
new file mode 100644
index 0000000..2734b8e
--- /dev/null
+++ b/src/app/(dashboard)/inventory/page.tsx
@@ -0,0 +1,62 @@
+import { db } from "@/lib/db";
+import { AddItemButton } from "@/components/inventory/item-dialog";
+import { InventoryGrid, type GridItem } from "@/components/inventory/inventory-grid";
+import { totalValue } from "@/lib/inventory/value";
+
+export const metadata = { title: "Inventory" };
+
+export default async function InventoryPage() {
+ const [items, locations, labels] = await Promise.all([
+ db.item.findMany({
+ where: { active: true },
+ orderBy: { name: "asc" },
+ include: {
+ location: { select: { id: true, name: true } },
+ parentItem: { select: { id: true, name: true } },
+ labels: { include: { label: true } },
+ _count: { select: { containedItems: { where: { active: true } }, logs: true } },
+ },
+ }),
+ db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
+ db.label.findMany({ orderBy: { name: "asc" } }),
+ ]);
+
+ // Convert Prisma Decimals to plain numbers before handing to client components.
+ const gridItems: GridItem[] = items.map((i) => ({
+ id: i.id,
+ name: i.name,
+ quantity: Number(i.quantity),
+ unit: i.unit,
+ value: i.value != null ? Number(i.value) : null,
+ warrantyExpiry: i.warrantyExpiry,
+ imageUrl: i.imageUrl,
+ locationId: i.locationId,
+ parentItemId: i.parentItemId,
+ location: i.location,
+ labels: i.labels,
+ _count: i._count,
+ }));
+
+ const total = totalValue(items.map((i) => ({
+ value: i.value != null ? Number(i.value) : null,
+ quantity: Number(i.quantity),
+ })));
+ const itemOpts = items.map((i) => ({ id: i.id, name: i.name }));
+
+ return (
+
+
+
+
Inventory
+
+ {items.length} {items.length === 1 ? "item" : "items"}
+ {total > 0 && ` · ${total.toLocaleString(undefined, { style: "currency", currency: "USD" })} total value`}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/api/admin/backup/route.ts b/src/app/api/admin/backup/route.ts
index 0a4204a..0ddf5a0 100644
--- a/src/app/api/admin/backup/route.ts
+++ b/src/app/api/admin/backup/route.ts
@@ -10,7 +10,8 @@ export async function GET() {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
- const [locations, plantTypes, users, apiTokens, plantGroups, plants, plantLogs, tasks, taskCompletions, auditEvents] =
+ const [locations, plantTypes, users, apiTokens, plantGroups, plants, plantLogs, tasks, taskCompletions,
+ labels, items, labelOnItems, itemAttachments, itemLogs, auditEvents] =
await Promise.all([
db.location.findMany(),
db.plantType.findMany(),
@@ -21,13 +22,18 @@ export async function GET() {
db.plantLog.findMany(),
db.task.findMany(),
db.taskCompletion.findMany(),
+ db.label.findMany(),
+ db.item.findMany(),
+ db.labelOnItem.findMany(),
+ db.itemAttachment.findMany(),
+ db.itemLog.findMany(),
db.auditEvent.findMany({ orderBy: { createdAt: "asc" } }),
]);
const manifest = {
version: 1,
exportedAt: new Date().toISOString(),
- tables: ["locations", "plantTypes", "users", "apiTokens", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "auditEvents"],
+ tables: ["locations", "plantTypes", "users", "apiTokens", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "labels", "items", "labelOnItems", "itemAttachments", "itemLogs", "auditEvents"],
};
const zip = zipSync({
@@ -41,6 +47,11 @@ export async function GET() {
"plantLogs.json": strToU8(JSON.stringify(plantLogs, null, 2)),
"tasks.json": strToU8(JSON.stringify(tasks, null, 2)),
"taskCompletions.json": strToU8(JSON.stringify(taskCompletions, null, 2)),
+ "labels.json": strToU8(JSON.stringify(labels, null, 2)),
+ "items.json": strToU8(JSON.stringify(items, null, 2)),
+ "labelOnItems.json": strToU8(JSON.stringify(labelOnItems, null, 2)),
+ "itemAttachments.json": strToU8(JSON.stringify(itemAttachments, null, 2)),
+ "itemLogs.json": strToU8(JSON.stringify(itemLogs, null, 2)),
"auditEvents.json": strToU8(JSON.stringify(auditEvents, null, 2)),
});
diff --git a/src/app/api/admin/restore/route.ts b/src/app/api/admin/restore/route.ts
index 49565ed..89c9f63 100644
--- a/src/app/api/admin/restore/route.ts
+++ b/src/app/api/admin/restore/route.ts
@@ -80,13 +80,23 @@ export async function POST(req: Request) {
const plantLogs = asArray("plantLogs.json");
const tasks = asArray("tasks.json");
const taskCompletions = asArray("taskCompletions.json");
+ const labels = files["labels.json"] ? asArray("labels.json") : [];
+ const items = files["items.json"] ? asArray("items.json") : [];
+ const labelOnItems = files["labelOnItems.json"] ? asArray("labelOnItems.json") : [];
+ const itemAttachments = files["itemAttachments.json"] ? asArray("itemAttachments.json") : [];
+ const itemLogs = files["itemLogs.json"] ? asArray("itemLogs.json") : [];
const auditEvents = asArray("auditEvents.json");
// Restore inside a transaction — wipe then reload in FK order.
await db.$transaction(async (tx) => {
await tx.taskCompletion.deleteMany();
await tx.plantLog.deleteMany();
+ await tx.itemLog.deleteMany();
+ await tx.itemAttachment.deleteMany();
+ await tx.labelOnItem.deleteMany();
await tx.task.deleteMany();
+ await tx.item.deleteMany();
+ await tx.label.deleteMany();
await tx.plant.deleteMany();
await tx.plantGroup.deleteMany();
await tx.plantType.deleteMany();
@@ -104,6 +114,13 @@ export async function POST(req: Request) {
if (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups as never });
if (plants.length) await tx.plant.createMany({ data: plants as never });
if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs as never });
+ if (labels.length) await tx.label.createMany({ data: labels as never });
+ // Item self-references parentItemId; one createMany statement validates the
+ // FK only after all rows exist — order-independent.
+ if (items.length) await tx.item.createMany({ data: items as never });
+ if (labelOnItems.length) await tx.labelOnItem.createMany({ data: labelOnItems as never });
+ if (itemAttachments.length) await tx.itemAttachment.createMany({ data: itemAttachments as never });
+ if (itemLogs.length) await tx.itemLog.createMany({ data: itemLogs as never });
if (tasks.length) await tx.task.createMany({ data: tasks as never });
if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions as never });
if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents as never });
diff --git a/src/app/api/v1/items/[id]/attachments/route.ts b/src/app/api/v1/items/[id]/attachments/route.ts
new file mode 100644
index 0000000..9565902
--- /dev/null
+++ b/src/app/api/v1/items/[id]/attachments/route.ts
@@ -0,0 +1,49 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { authenticateRequest, handleApiError, ApiError } from "@/lib/api/authenticate";
+
+export const dynamic = "force-dynamic";
+
+const KINDS = new Set(["PHOTO", "MANUAL", "RECEIPT"]);
+
+// POST /api/v1/items/:id/attachments — multipart upload (file, kind). Files land
+// under public/uploads/items (a persistent Docker volume), same as suggestions.
+export async function POST(req: Request, { params }: { params: { id: string } }) {
+ try {
+ const ctx = await authenticateRequest(req, "items:write");
+
+ const item = await db.item.findUnique({ where: { id: params.id }, select: { active: true, imageUrl: true } });
+ if (!item || !item.active) throw new ApiError(404, "Item not found");
+
+ const form = await req.formData();
+ const file = form.get("file") as File | null;
+ const kind = String(form.get("kind") ?? "PHOTO").toUpperCase();
+ if (!file) throw new ApiError(400, "No file uploaded");
+ if (!KINDS.has(kind)) throw new ApiError(400, "Invalid attachment kind");
+
+ const safe = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
+ const dir = path.join(process.cwd(), "public", "uploads", "items");
+ await mkdir(dir, { recursive: true });
+ await writeFile(path.join(dir, safe), Buffer.from(await file.arrayBuffer()));
+ const base = (process.env.NEXT_PUBLIC_BASE_URL || process.env.NEXTAUTH_URL || "").replace(/\/+$/, "");
+ const url = base ? `${base}/uploads/items/${safe}` : `/uploads/items/${safe}`;
+
+ const attachment = await db.itemAttachment.create({
+ data: { itemId: params.id, kind, url, name: file.name },
+ });
+
+ // First photo becomes the item's thumbnail.
+ if (kind === "PHOTO" && !item.imageUrl) {
+ await db.item.update({ where: { id: params.id }, data: { imageUrl: url } });
+ }
+ await db.auditEvent.create({
+ data: { action: "item.attachment.add", entityId: params.id, actorId: ctx.userId, payload: { kind, via: ctx.via } },
+ });
+
+ return NextResponse.json(attachment, { status: 201 });
+ } catch (err) {
+ return handleApiError(err);
+ }
+}
diff --git a/src/app/api/v1/items/[id]/logs/route.ts b/src/app/api/v1/items/[id]/logs/route.ts
new file mode 100644
index 0000000..1f6a32e
--- /dev/null
+++ b/src/app/api/v1/items/[id]/logs/route.ts
@@ -0,0 +1,15 @@
+import { NextResponse } from "next/server";
+import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
+import { itemLogInput } from "@/lib/api/schemas";
+import { addItemLog } from "@/lib/services/items";
+
+// POST /api/v1/items/:id/logs
+export async function POST(req: Request, { params }: { params: { id: string } }) {
+ try {
+ const ctx = await authenticateRequest(req, "items:write");
+ const input = itemLogInput.parse(await req.json());
+ return NextResponse.json(await addItemLog(ctx, params.id, input), { status: 201 });
+ } catch (err) {
+ return handleApiError(err);
+ }
+}
diff --git a/src/app/api/v1/items/[id]/route.ts b/src/app/api/v1/items/[id]/route.ts
new file mode 100644
index 0000000..9c814ee
--- /dev/null
+++ b/src/app/api/v1/items/[id]/route.ts
@@ -0,0 +1,36 @@
+import { NextResponse } from "next/server";
+import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
+import { itemUpdateInput } from "@/lib/api/schemas";
+import { getItem, updateItem, deleteItem } from "@/lib/services/items";
+
+// GET /api/v1/items/:id
+export async function GET(req: Request, { params }: { params: { id: string } }) {
+ try {
+ const ctx = await authenticateRequest(req, "items:read");
+ return NextResponse.json(await getItem(ctx, params.id));
+ } catch (err) {
+ return handleApiError(err);
+ }
+}
+
+// PATCH /api/v1/items/:id
+export async function PATCH(req: Request, { params }: { params: { id: string } }) {
+ try {
+ const ctx = await authenticateRequest(req, "items:write");
+ const input = itemUpdateInput.parse(await req.json());
+ return NextResponse.json(await updateItem(ctx, params.id, input));
+ } catch (err) {
+ return handleApiError(err);
+ }
+}
+
+// DELETE /api/v1/items/:id (soft-delete; admin only — enforced in the service)
+export async function DELETE(req: Request, { params }: { params: { id: string } }) {
+ try {
+ const ctx = await authenticateRequest(req, "items:write");
+ await deleteItem(ctx, params.id);
+ return NextResponse.json({ ok: true });
+ } catch (err) {
+ return handleApiError(err);
+ }
+}
diff --git a/src/app/api/v1/items/route.ts b/src/app/api/v1/items/route.ts
new file mode 100644
index 0000000..f94dbf4
--- /dev/null
+++ b/src/app/api/v1/items/route.ts
@@ -0,0 +1,34 @@
+import { NextResponse } from "next/server";
+import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
+import { itemCreateInput } from "@/lib/api/schemas";
+import { listItems, createItem } from "@/lib/services/items";
+
+// GET /api/v1/items?q=&locationId=&kind=DURABLE
+export async function GET(req: Request) {
+ try {
+ const ctx = await authenticateRequest(req, "items:read");
+ const sp = new URL(req.url).searchParams;
+ const kindParam = sp.get("kind");
+ const kind = kindParam === "DURABLE" || kindParam === "CONSUMABLE" ? kindParam : undefined;
+ return NextResponse.json(
+ await listItems(ctx, {
+ q: sp.get("q") ?? undefined,
+ locationId: sp.get("locationId") ?? undefined,
+ kind,
+ }),
+ );
+ } catch (err) {
+ return handleApiError(err);
+ }
+}
+
+// POST /api/v1/items
+export async function POST(req: Request) {
+ try {
+ const ctx = await authenticateRequest(req, "items:write");
+ const input = itemCreateInput.parse(await req.json());
+ return NextResponse.json(await createItem(ctx, input), { status: 201 });
+ } catch (err) {
+ return handleApiError(err);
+ }
+}
diff --git a/src/app/api/v1/labels/route.ts b/src/app/api/v1/labels/route.ts
new file mode 100644
index 0000000..0898f93
--- /dev/null
+++ b/src/app/api/v1/labels/route.ts
@@ -0,0 +1,37 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { db } from "@/lib/db";
+import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
+
+const createSchema = z.object({
+ name: z.string().min(1),
+ color: z.string().optional(),
+});
+
+// GET /api/v1/labels
+export async function GET(req: Request) {
+ try {
+ await authenticateRequest(req, "items:read");
+ return NextResponse.json(await db.label.findMany({ orderBy: { name: "asc" } }));
+ } catch (err) {
+ return handleApiError(err);
+ }
+}
+
+// POST /api/v1/labels — create (or return existing by name).
+export async function POST(req: Request) {
+ try {
+ const ctx = await authenticateRequest(req, "items:write");
+ const data = createSchema.parse(await req.json());
+ const name = data.name.trim();
+ const existing = await db.label.findUnique({ where: { name } });
+ if (existing) return NextResponse.json(existing);
+ const label = await db.label.create({ data: { name, color: data.color?.trim() || null } });
+ await db.auditEvent.create({
+ data: { action: "label.create", entityId: label.id, actorId: ctx.userId, payload: { name } },
+ });
+ return NextResponse.json(label, { status: 201 });
+ } catch (err) {
+ return handleApiError(err);
+ }
+}
diff --git a/src/components/inventory/add-item-log-button.tsx b/src/components/inventory/add-item-log-button.tsx
new file mode 100644
index 0000000..9bab435
--- /dev/null
+++ b/src/components/inventory/add-item-log-button.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
+} from "@/components/ui/dialog";
+import {
+ Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
+} from "@/components/ui/select";
+import { useToast } from "@/hooks/use-toast";
+import { ClipboardList } from "lucide-react";
+import { ITEM_LOG_TYPES, ITEM_LOG_LABELS } from "@/lib/inventory/item-fields";
+
+export function AddItemLogButton({ itemId }: { itemId: string }) {
+ const [open, setOpen] = useState(false);
+ const [type, setType] = useState("MAINTENANCE");
+ const [notes, setNotes] = useState("");
+ const [cost, setCost] = useState("");
+ const [busy, setBusy] = useState(false);
+ const router = useRouter();
+ const { toast } = useToast();
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setBusy(true);
+ const res = await fetch(`/api/v1/items/${itemId}/logs`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ type, notes: notes.trim() || undefined, cost: cost ? Number(cost) : null }),
+ });
+ setBusy(false);
+ if (!res.ok) { toast({ title: "Error", description: "Could not save log.", variant: "destructive" }); return; }
+ toast({ title: "Logged!" });
+ setNotes(""); setCost(""); setOpen(false);
+ router.refresh();
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/components/inventory/attachment-upload.tsx b/src/components/inventory/attachment-upload.tsx
new file mode 100644
index 0000000..36a8532
--- /dev/null
+++ b/src/components/inventory/attachment-upload.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { useToast } from "@/hooks/use-toast";
+import { ImagePlus } from "lucide-react";
+
+export function AttachmentUpload({ itemId }: { itemId: string }) {
+ const [busy, setBusy] = useState(false);
+ const inputRef = useRef(null);
+ const router = useRouter();
+ const { toast } = useToast();
+
+ async function onFile(file: File) {
+ setBusy(true);
+ const form = new FormData();
+ form.append("file", file);
+ form.append("kind", "PHOTO");
+ const res = await fetch(`/api/v1/items/${itemId}/attachments`, { method: "POST", body: form });
+ setBusy(false);
+ if (res.ok) { toast({ title: "Photo added" }); router.refresh(); }
+ else toast({ title: "Error", description: "Upload failed.", variant: "destructive" });
+ }
+
+ return (
+ <>
+ { const f = e.target.files?.[0]; if (f) onFile(f); e.target.value = ""; }} />
+
+ >
+ );
+}
diff --git a/src/components/inventory/delete-item-button.tsx b/src/components/inventory/delete-item-button.tsx
new file mode 100644
index 0000000..aa4bbfc
--- /dev/null
+++ b/src/components/inventory/delete-item-button.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
+} from "@/components/ui/dialog";
+import { useToast } from "@/hooks/use-toast";
+import { Trash2 } from "lucide-react";
+
+export function DeleteItemButton({ id, name }: { id: string; name: string }) {
+ const [open, setOpen] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const router = useRouter();
+ const { toast } = useToast();
+
+ async function handleDelete() {
+ setBusy(true);
+ const res = await fetch(`/api/v1/items/${id}`, { method: "DELETE" });
+ setBusy(false);
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ toast({ title: "Error", description: err.error ?? "Could not delete.", variant: "destructive" });
+ return;
+ }
+ toast({ title: "Deleted", description: `"${name}" removed.` });
+ setOpen(false);
+ router.push("/inventory");
+ router.refresh();
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/components/inventory/inventory-grid.tsx b/src/components/inventory/inventory-grid.tsx
new file mode 100644
index 0000000..650313e
--- /dev/null
+++ b/src/components/inventory/inventory-grid.tsx
@@ -0,0 +1,145 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { Badge } from "@/components/ui/badge";
+import {
+ Package, MapPin, ChevronDown, ChevronRight, Box, ShieldCheck, ShieldAlert, ShieldX,
+} from "lucide-react";
+import { warrantyStatus } from "@/lib/inventory/value";
+
+export type GridItem = {
+ id: string;
+ name: string;
+ quantity: string | number;
+ unit: string | null;
+ value: string | number | null;
+ warrantyExpiry: string | Date | null;
+ imageUrl: string | null;
+ locationId: string | null;
+ parentItemId: string | null;
+ location: { id: string; name: string } | null;
+ labels: { label: { id: string; name: string; color: string | null } }[];
+ _count: { containedItems: number; logs: number };
+};
+
+const now = new Date();
+
+function WarrantyBadge({ expiry }: { expiry: string | Date | null }) {
+ const s = warrantyStatus(expiry, now);
+ if (s === "none") return null;
+ const map = {
+ active: { icon: ShieldCheck, cls: "text-green-600 border-green-600/30", label: "Warranty" },
+ expiring: { icon: ShieldAlert, cls: "text-amber-600 border-amber-600/30", label: "Warranty soon" },
+ expired: { icon: ShieldX, cls: "text-red-600 border-red-600/30", label: "Warranty out" },
+ }[s];
+ const Icon = map.icon;
+ return {map.label};
+}
+
+function ItemRow({ item, childrenByParent, depth }: {
+ item: GridItem;
+ childrenByParent: Map;
+ depth: number;
+}) {
+ const [open, setOpen] = useState(false);
+ const kids = childrenByParent.get(item.id) ?? [];
+ const qty = Number(item.quantity);
+
+ return (
+
+
+ {kids.length > 0 ? (
+
+ ) :
}
+
+ {item.imageUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+ {kids.length > 0 ? : }
+
+ )}
+
+
+
{item.name}
+ {qty !== 1 &&
· {qty}{item.unit ? ` ${item.unit}` : ""}}
+ {kids.length > 0 &&
· holds {kids.length}}
+
+
+
+ {item.labels.slice(0, 3).map((l) => (
+ {l.label.name}
+ ))}
+
+
+
+ {open && kids.map((k) => (
+
+ ))}
+
+ );
+}
+
+export function InventoryGrid({ items }: { items: GridItem[] }) {
+ if (items.length === 0) {
+ return (
+
+
+
No items yet
+
Add your first tool, gadget, or piece of gear.
+
+ );
+ }
+
+ // Items contained inside another item are rendered under their parent.
+ const childrenByParent = new Map();
+ for (const it of items) {
+ if (it.parentItemId) {
+ const arr = childrenByParent.get(it.parentItemId) ?? [];
+ arr.push(it);
+ childrenByParent.set(it.parentItemId, arr);
+ }
+ }
+
+ // Top-level items (not inside another item) grouped by location.
+ const topLevel = items.filter((i) => !i.parentItemId);
+ const groups = new Map();
+ const noLoc: GridItem[] = [];
+ for (const it of topLevel) {
+ if (!it.location) { noLoc.push(it); continue; }
+ const g = groups.get(it.location.id) ?? { name: it.location.name, items: [] };
+ g.items.push(it);
+ groups.set(it.location.id, g);
+ }
+ const sorted = Array.from(groups.values()).sort((a, b) => a.name.localeCompare(b.name));
+
+ return (
+
+ {sorted.map((g) => (
+
+
+
+ {g.name}
+ · {g.items.length}
+
+
+ {g.items.map((it) => )}
+
+
+ ))}
+
+ {noLoc.length > 0 && (
+
+ {sorted.length > 0 &&
No location
}
+
+ {noLoc.map((it) => )}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/inventory/item-dialog.tsx b/src/components/inventory/item-dialog.tsx
new file mode 100644
index 0000000..85fac6d
--- /dev/null
+++ b/src/components/inventory/item-dialog.tsx
@@ -0,0 +1,252 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
+} from "@/components/ui/dialog";
+import {
+ Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
+} from "@/components/ui/select";
+import { Badge } from "@/components/ui/badge";
+import { useToast } from "@/hooks/use-toast";
+import { Plus, Pencil } from "lucide-react";
+
+export type LocationOpt = { id: string; name: string };
+export type ItemOpt = { id: string; name: string };
+export type LabelOpt = { id: string; name: string; color: string | null };
+
+export type ItemFields = {
+ id?: string;
+ name: string;
+ description: string;
+ quantity: string;
+ unit: string;
+ locationId: string;
+ parentItemId: string;
+ brand: string;
+ modelNumber: string;
+ serial: string;
+ value: string;
+ purchaseDate: string;
+ warrantyExpiry: string;
+ barcode: string;
+ notes: string;
+ labelIds: string[];
+};
+
+export function blankItem(): ItemFields {
+ return {
+ name: "", description: "", quantity: "1", unit: "", locationId: "", parentItemId: "",
+ brand: "", modelNumber: "", serial: "", value: "", purchaseDate: "", warrantyExpiry: "",
+ barcode: "", notes: "", labelIds: [],
+ };
+}
+
+const NONE = "__none__";
+
+function ItemDialog({
+ trigger, title, initial, locations, items, labels: initialLabels,
+}: {
+ trigger: React.ReactNode;
+ title: string;
+ initial: ItemFields;
+ locations: LocationOpt[];
+ items: ItemOpt[];
+ labels: LabelOpt[];
+}) {
+ const [open, setOpen] = useState(false);
+ const [f, setF] = useState(initial);
+ const [labels, setLabels] = useState(initialLabels);
+ const [newLabel, setNewLabel] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+ const router = useRouter();
+ const { toast } = useToast();
+
+ function set(k: K, v: ItemFields[K]) {
+ setF((p) => ({ ...p, [k]: v }));
+ }
+ function toggleLabel(id: string) {
+ setF((p) => ({ ...p, labelIds: p.labelIds.includes(id) ? p.labelIds.filter((x) => x !== id) : [...p.labelIds, id] }));
+ }
+ async function addLabel() {
+ const name = newLabel.trim();
+ if (!name) return;
+ const res = await fetch("/api/v1/labels", {
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }),
+ });
+ if (res.ok) {
+ const lab = await res.json();
+ setLabels((p) => (p.some((l) => l.id === lab.id) ? p : [...p, lab]));
+ toggleLabel(lab.id);
+ setNewLabel("");
+ }
+ }
+
+ // Items selectable as a parent — exclude self.
+ const parentOptions = items.filter((i) => i.id !== f.id);
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!f.name.trim()) { setError("Please give the item a name."); return; }
+ setError("");
+ setBusy(true);
+
+ const payload = {
+ name: f.name.trim(),
+ description: f.description.trim() || undefined,
+ quantity: f.quantity ? Number(f.quantity) : undefined,
+ unit: f.unit.trim() || undefined,
+ locationId: f.locationId || null,
+ parentItemId: f.parentItemId || null,
+ brand: f.brand.trim() || undefined,
+ modelNumber: f.modelNumber.trim() || undefined,
+ serial: f.serial.trim() || undefined,
+ value: f.value ? Number(f.value) : null,
+ purchaseDate: f.purchaseDate || null,
+ warrantyExpiry: f.warrantyExpiry || null,
+ barcode: f.barcode.trim() || undefined,
+ notes: f.notes.trim() || undefined,
+ labelIds: f.labelIds,
+ };
+
+ const res = await fetch(f.id ? `/api/v1/items/${f.id}` : "/api/v1/items", {
+ method: f.id ? "PATCH" : "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ setBusy(false);
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ toast({ title: "Error", description: err.error ?? "Could not save.", variant: "destructive" });
+ return;
+ }
+ toast({ title: f.id ? "Saved!" : "Item added!" });
+ setOpen(false);
+ router.refresh();
+ }
+
+ return (
+ <>
+ { setF(initial); setLabels(initialLabels); setOpen(true); }}>{trigger}
+
+ >
+ );
+}
+
+export function AddItemButton(props: { locations: LocationOpt[]; items: ItemOpt[]; labels: LabelOpt[] }) {
+ return (
+ Add item} />
+ );
+}
+
+export function EditItemButton(props: { item: ItemFields; locations: LocationOpt[]; items: ItemOpt[]; labels: LabelOpt[] }) {
+ const { item, ...rest } = props;
+ return (
+ Edit} />
+ );
+}
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx
index 325b8c4..7f35292 100644
--- a/src/components/layout/sidebar.tsx
+++ b/src/components/layout/sidebar.tsx
@@ -2,13 +2,14 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
-import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw, BookOpen, KeyRound } from "lucide-react";
+import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw, BookOpen, KeyRound, Package } from "lucide-react";
import { cn } from "@/lib/utils";
const navItems = [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ label: "Garden", href: "/garden", icon: Leaf },
{ label: "Plant types", href: "/garden/types", icon: BookOpen },
+ { label: "Inventory", href: "/inventory", icon: Package },
{ label: "Reminders", href: "/tasks", icon: Bell },
// Home maintenance and Equipment come in future releases
// { label: "Home", href: "/home", icon: Home },
diff --git a/src/lib/api/schemas.ts b/src/lib/api/schemas.ts
index fe5a883..05f5366 100644
--- a/src/lib/api/schemas.ts
+++ b/src/lib/api/schemas.ts
@@ -22,6 +22,40 @@ export const taskCreateInput = z.object({
export const taskCompleteInput = z.object({ notes: z.string().optional() });
+// --- Inventory ---
+
+export const itemCreateInput = z.object({
+ name: z.string().min(1),
+ description: z.string().optional(),
+ kind: z.enum(["DURABLE", "CONSUMABLE"]).default("DURABLE"),
+ quantity: z.coerce.number().nonnegative().optional(),
+ unit: z.string().optional(),
+ locationId: z.string().nullable().optional(),
+ parentItemId: z.string().nullable().optional(),
+ value: z.coerce.number().nonnegative().nullable().optional(),
+ purchaseDate: z.string().nullable().optional(),
+ warrantyExpiry: z.string().nullable().optional(),
+ serial: z.string().optional(),
+ modelNumber: z.string().optional(),
+ brand: z.string().optional(),
+ barcode: z.string().optional(),
+ notes: z.string().optional(),
+ imageUrl: z.string().nullable().optional(),
+ labelIds: z.array(z.string()).optional(),
+});
+
+export const itemUpdateInput = itemCreateInput.partial();
+
+export const itemLogInput = z.object({
+ type: z.enum(["NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED"]),
+ notes: z.string().optional(),
+ cost: z.coerce.number().nonnegative().nullable().optional(),
+ date: z.string().optional(),
+});
+
export type PlantLogInput = z.infer;
export type TaskCreateInput = z.infer;
export type TaskCompleteInput = z.infer;
+export type ItemCreateInput = z.infer;
+export type ItemUpdateInput = z.infer;
+export type ItemLogInput = z.infer;
diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts
index 56b5283..03477a1 100644
--- a/src/lib/changelog.ts
+++ b/src/lib/changelog.ts
@@ -8,6 +8,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
+ {
+ version: "0.13.0",
+ date: "2026-06-16",
+ changes: [
+ "New Inventory section — keep track of the things you own: tools, gear, equipment. Record where each one lives, its value, brand, serial number, and warranty (you'll get a heads-up when one's expiring). Items can hold other items, so a toolbox shows the tools inside it. Add photos, labels to group things, and a maintenance/repair history per item.",
+ ],
+ },
{
version: "0.12.0",
date: "2026-06-15",
diff --git a/src/lib/inventory/item-fields.ts b/src/lib/inventory/item-fields.ts
new file mode 100644
index 0000000..9ecbc40
--- /dev/null
+++ b/src/lib/inventory/item-fields.ts
@@ -0,0 +1,26 @@
+import { ItemLogType } from "@prisma/client";
+
+export const ITEM_LOG_LABELS: Record = {
+ NOTE: "Note",
+ MAINTENANCE: "Maintenance",
+ MOVED: "Moved",
+ PURCHASED: "Purchased",
+ REPAIRED: "Repaired",
+ DISPOSED: "Disposed",
+};
+
+export const ITEM_LOG_COLORS: Record = {
+ NOTE: "text-slate-600 dark:text-slate-400",
+ MAINTENANCE: "text-amber-600 dark:text-amber-400",
+ MOVED: "text-blue-600 dark:text-blue-400",
+ PURCHASED: "text-green-600 dark:text-green-400",
+ REPAIRED: "text-violet-600 dark:text-violet-400",
+ DISPOSED: "text-red-600 dark:text-red-400",
+};
+
+export const ITEM_LOG_TYPES: ItemLogType[] = [
+ "NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED",
+];
+
+export const ATTACHMENT_KINDS = ["PHOTO", "MANUAL", "RECEIPT"] as const;
+export type AttachmentKind = (typeof ATTACHMENT_KINDS)[number];
diff --git a/src/lib/inventory/value.test.ts b/src/lib/inventory/value.test.ts
new file mode 100644
index 0000000..8f743c7
--- /dev/null
+++ b/src/lib/inventory/value.test.ts
@@ -0,0 +1,46 @@
+import { describe, it, expect } from "vitest";
+import { warrantyStatus, totalValue } from "./value";
+
+const now = new Date("2026-06-16T00:00:00Z");
+
+describe("warrantyStatus", () => {
+ it("returns none when there's no expiry", () => {
+ expect(warrantyStatus(null, now)).toBe("none");
+ expect(warrantyStatus(undefined, now)).toBe("none");
+ });
+
+ it("flags expired warranties", () => {
+ expect(warrantyStatus(new Date("2026-06-15T00:00:00Z"), now)).toBe("expired");
+ });
+
+ it("flags warranties expiring within the window", () => {
+ expect(warrantyStatus(new Date("2026-07-01T00:00:00Z"), now)).toBe("expiring"); // ~15 days
+ });
+
+ it("treats a comfortably future expiry as active", () => {
+ expect(warrantyStatus(new Date("2027-01-01T00:00:00Z"), now)).toBe("active");
+ });
+
+ it("honors a custom soon window", () => {
+ expect(warrantyStatus(new Date("2026-08-01T00:00:00Z"), now, 90)).toBe("expiring");
+ expect(warrantyStatus(new Date("2026-08-01T00:00:00Z"), now, 7)).toBe("active");
+ });
+});
+
+describe("totalValue", () => {
+ it("sums value × quantity", () => {
+ expect(totalValue([{ value: 2, quantity: 5 }, { value: 100, quantity: 1 }])).toBe(110);
+ });
+
+ it("defaults quantity to 1 and missing value to 0", () => {
+ expect(totalValue([{ value: 50 }, { quantity: 3 }, {}])).toBe(50);
+ });
+
+ it("accepts Decimal-style strings", () => {
+ expect(totalValue([{ value: "19.99", quantity: "2" }])).toBeCloseTo(39.98);
+ });
+
+ it("is zero for an empty inventory", () => {
+ expect(totalValue([])).toBe(0);
+ });
+});
diff --git a/src/lib/inventory/value.ts b/src/lib/inventory/value.ts
new file mode 100644
index 0000000..96dfb18
--- /dev/null
+++ b/src/lib/inventory/value.ts
@@ -0,0 +1,39 @@
+// Pure inventory helpers — warranty status + total value. Decimal columns come
+// back as strings/Decimal from Prisma, so callers pass plain numbers here.
+
+export type WarrantyStatus = "none" | "active" | "expiring" | "expired";
+
+/**
+ * Where a warranty stands relative to `now`. "expiring" = within `soonDays`.
+ */
+export function warrantyStatus(
+ warrantyExpiry: Date | string | null | undefined,
+ now: Date,
+ soonDays = 30,
+): WarrantyStatus {
+ if (!warrantyExpiry) return "none";
+ const exp = new Date(warrantyExpiry).getTime();
+ const t = now.getTime();
+ if (Number.isNaN(exp)) return "none";
+ if (exp < t) return "expired";
+ if (exp - t <= soonDays * 86_400_000) return "expiring";
+ return "active";
+}
+
+export const WARRANTY_LABELS: Record = {
+ none: "",
+ active: "Under warranty",
+ expiring: "Warranty expiring soon",
+ expired: "Warranty expired",
+};
+
+type Valued = { value?: number | string | null; quantity?: number | string | null };
+
+/** Sum of value × quantity across items (missing value counts as 0). */
+export function totalValue(items: Valued[]): number {
+ return items.reduce((sum, i) => {
+ const v = i.value != null ? Number(i.value) : 0;
+ const q = i.quantity != null ? Number(i.quantity) : 1;
+ return sum + (Number.isFinite(v) ? v * (Number.isFinite(q) ? q : 1) : 0);
+ }, 0);
+}
diff --git a/src/lib/services/items.ts b/src/lib/services/items.ts
new file mode 100644
index 0000000..920cfed
--- /dev/null
+++ b/src/lib/services/items.ts
@@ -0,0 +1,173 @@
+// Inventory service — DB work + AuditEvent for items. Shared by /api/v1 routes,
+// the UI (which calls those routes), and later the MCP server.
+import { createId } from "@paralleldrive/cuid2";
+import { Prisma } from "@prisma/client";
+import { db } from "@/lib/db";
+import { isAdmin } from "@/lib/auth";
+import { ApiError, type AuthContext } from "@/lib/api/authenticate";
+import type { ItemCreateInput, ItemUpdateInput, ItemLogInput } from "@/lib/api/schemas";
+
+const listInclude = {
+ location: { select: { id: true, name: true } },
+ parentItem: { select: { id: true, name: true } },
+ labels: { include: { label: true } },
+ _count: { select: { containedItems: { where: { active: true } }, logs: true } },
+} satisfies Prisma.ItemInclude;
+
+function toDate(s?: string | null): Date | null {
+ return s ? new Date(s) : null;
+}
+
+export function listItems(
+ _ctx: AuthContext,
+ opts?: { q?: string; locationId?: string; kind?: "DURABLE" | "CONSUMABLE" },
+) {
+ return db.item.findMany({
+ where: {
+ active: true,
+ ...(opts?.q ? { name: { contains: opts.q, mode: "insensitive" } } : {}),
+ ...(opts?.locationId ? { locationId: opts.locationId } : {}),
+ ...(opts?.kind ? { kind: opts.kind } : {}),
+ },
+ orderBy: { name: "asc" },
+ include: listInclude,
+ });
+}
+
+export async function getItem(_ctx: AuthContext, id: string) {
+ const item = await db.item.findUnique({
+ where: { id },
+ include: {
+ ...listInclude,
+ attachments: { orderBy: { createdAt: "asc" } },
+ logs: { orderBy: { date: "desc" }, take: 50 },
+ containedItems: {
+ where: { active: true },
+ orderBy: { name: "asc" },
+ include: { _count: { select: { containedItems: { where: { active: true } } } } },
+ },
+ },
+ });
+ if (!item || !item.active) throw new ApiError(404, "Item not found");
+ return item;
+}
+
+// Walk up from a candidate parent; if we reach the item itself it's a cycle.
+async function wouldCycle(itemId: string, candidateParentId: string): Promise {
+ let cur: string | null = candidateParentId;
+ const seen = new Set();
+ while (cur) {
+ if (cur === itemId) return true;
+ if (seen.has(cur)) break;
+ seen.add(cur);
+ const parent: { parentItemId: string | null } | null = await db.item.findUnique({
+ where: { id: cur },
+ select: { parentItemId: true },
+ });
+ cur = parent?.parentItemId ?? null;
+ }
+ return false;
+}
+
+export async function createItem(ctx: AuthContext, input: ItemCreateInput) {
+ const { labelIds, ...r } = input;
+ const item = await db.item.create({
+ data: {
+ name: r.name.trim(),
+ description: r.description?.trim() || null,
+ kind: r.kind ?? "DURABLE",
+ quantity: r.quantity ?? 1,
+ unit: r.unit?.trim() || null,
+ locationId: r.locationId || null,
+ parentItemId: r.parentItemId || null,
+ value: r.value ?? null,
+ purchaseDate: toDate(r.purchaseDate),
+ warrantyExpiry: toDate(r.warrantyExpiry),
+ serial: r.serial?.trim() || null,
+ modelNumber: r.modelNumber?.trim() || null,
+ brand: r.brand?.trim() || null,
+ barcode: r.barcode?.trim() || null,
+ notes: r.notes?.trim() || null,
+ imageUrl: r.imageUrl || null,
+ qrSlug: createId().slice(0, 10),
+ ...(labelIds?.length ? { labels: { create: labelIds.map((labelId) => ({ labelId })) } } : {}),
+ },
+ include: listInclude,
+ });
+ await db.auditEvent.create({
+ data: { action: "item.create", entityId: item.id, actorId: ctx.userId, payload: { name: item.name, via: ctx.via } },
+ });
+ return item;
+}
+
+export async function updateItem(ctx: AuthContext, id: string, input: ItemUpdateInput) {
+ const existing = await db.item.findUnique({ where: { id }, select: { active: true } });
+ if (!existing || !existing.active) throw new ApiError(404, "Item not found");
+
+ if (input.parentItemId) {
+ if (input.parentItemId === id) throw new ApiError(400, "An item can't contain itself");
+ if (await wouldCycle(id, input.parentItemId))
+ throw new ApiError(400, "That would put the item inside one of its own contents");
+ }
+
+ const { labelIds, ...r } = input;
+ const data: Prisma.ItemUpdateInput = {};
+ if (r.name !== undefined) data.name = r.name.trim();
+ if (r.description !== undefined) data.description = r.description?.trim() || null;
+ if (r.kind !== undefined) data.kind = r.kind;
+ if (r.quantity !== undefined) data.quantity = r.quantity;
+ if (r.unit !== undefined) data.unit = r.unit?.trim() || null;
+ if (r.locationId !== undefined) data.location = r.locationId ? { connect: { id: r.locationId } } : { disconnect: true };
+ if (r.parentItemId !== undefined) data.parentItem = r.parentItemId ? { connect: { id: r.parentItemId } } : { disconnect: true };
+ if (r.value !== undefined) data.value = r.value;
+ if (r.purchaseDate !== undefined) data.purchaseDate = toDate(r.purchaseDate);
+ if (r.warrantyExpiry !== undefined) data.warrantyExpiry = toDate(r.warrantyExpiry);
+ if (r.serial !== undefined) data.serial = r.serial?.trim() || null;
+ if (r.modelNumber !== undefined) data.modelNumber = r.modelNumber?.trim() || null;
+ if (r.brand !== undefined) data.brand = r.brand?.trim() || null;
+ if (r.barcode !== undefined) data.barcode = r.barcode?.trim() || null;
+ if (r.notes !== undefined) data.notes = r.notes?.trim() || null;
+ if (r.imageUrl !== undefined) data.imageUrl = r.imageUrl || null;
+
+ const item = await db.$transaction(async (tx) => {
+ if (labelIds) {
+ await tx.labelOnItem.deleteMany({ where: { itemId: id } });
+ if (labelIds.length) await tx.labelOnItem.createMany({ data: labelIds.map((labelId) => ({ itemId: id, labelId })) });
+ }
+ return tx.item.update({ where: { id }, data, include: listInclude });
+ });
+
+ await db.auditEvent.create({
+ data: { action: "item.update", entityId: id, actorId: ctx.userId, payload: { name: item.name, via: ctx.via } },
+ });
+ return item;
+}
+
+export async function deleteItem(ctx: AuthContext, id: string) {
+ if (!isAdmin(ctx.role)) throw new ApiError(403, "Only admins can delete items");
+ const item = await db.item.findUnique({ where: { id }, select: { active: true, name: true } });
+ if (!item || !item.active) throw new ApiError(404, "Item not found");
+ await db.item.update({ where: { id }, data: { active: false } });
+ await db.auditEvent.create({
+ data: { action: "item.delete", entityId: id, actorId: ctx.userId, payload: { name: item.name, via: ctx.via } },
+ });
+}
+
+export async function addItemLog(ctx: AuthContext, itemId: string, input: ItemLogInput) {
+ const item = await db.item.findUnique({ where: { id: itemId }, select: { active: true } });
+ if (!item || !item.active) throw new ApiError(404, "Item not found");
+ const log = await db.itemLog.create({
+ data: {
+ itemId,
+ type: input.type,
+ date: input.date ? new Date(input.date) : new Date(),
+ notes: input.notes?.trim() || null,
+ cost: input.cost ?? null,
+ actorId: ctx.userId,
+ },
+ });
+ await db.auditEvent.create({
+ data: { action: "item.log.add", entityId: itemId, actorId: ctx.userId, payload: { type: input.type, via: ctx.via } },
+ });
+ return log;
+}
diff --git a/src/lib/version.ts b/src/lib/version.ts
index 8ce5426..5b6ff40 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.12.0";
+export const APP_VERSION = "0.13.0";