v0.13.0 — Inventory (HomeBox): durable items that nest

The first inventory vertical on the shared spine. Things you own — tools, gear,
equipment — each living in a Location or inside another Item.

- Item model (kind DURABLE|CONSUMABLE; consumable fields present but unused so
  Pantry needs no migration), self-nesting via parentItemId (toolbox holds drill,
  with a cycle guard), value/warranty/serial/brand/qty, qrSlug, photo
- Label (m2m) + ItemAttachment (photos/manuals/receipts) + ItemLog
  (maintenance/repair/move/dispose) history; Task.itemId
- API-first: /api/v1/items (CRUD), labels, logs, multipart attachments — the UI
  consumes the same scoped endpoints. items service shared with the coming MCP
- Inventory pages: tree grouped by Location with items nested inside items;
  item detail with warranty status, photos, contents, history; add/edit/delete,
  log, photo upload. Nav entry. Backup/restore cover the new tables
- Pure value.ts (warrantyStatus, totalValue) with tests; verified end-to-end
  against a running server (nest, cycle-guard 400, log 201)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 00:12:03 -05:00
parent 0904790dca
commit b0118d3fec
25 changed files with 1613 additions and 5 deletions

View File

@@ -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;

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually # Please do not edit this file manually
# It should be added in your version-control system (i.e. Git) # It should be added in your version-control system (i.e. Git)
provider = "postgresql" provider = "postgresql"

View File

@@ -100,6 +100,7 @@ model Location {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
plants Plant[] plants Plant[]
items Item[]
@@index([parentId]) @@index([parentId])
@@index([kind]) @@index([kind])
@@ -236,6 +237,8 @@ model Task {
category TaskCategory @default(GARDEN) category TaskCategory @default(GARDEN)
plantId String? plantId String?
plant Plant? @relation(fields: [plantId], references: [id]) plant Plant? @relation(fields: [plantId], references: [id])
itemId String?
item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull)
recurrence RecurrenceType @default(YEARLY) recurrence RecurrenceType @default(YEARLY)
intervalDays Int? // for CUSTOM: every N days intervalDays Int? // for CUSTOM: every N days
month Int? // 112: for YEARLY, which month month Int? // 112: for YEARLY, which month
@@ -248,6 +251,7 @@ model Task {
@@index([nextDue]) @@index([nextDue])
@@index([plantId]) @@index([plantId])
@@index([itemId])
@@index([active]) @@index([active])
} }
@@ -276,3 +280,118 @@ model PlantLog {
@@index([plantId]) @@index([plantId])
@@index([date]) @@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])
}

View File

@@ -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: <MapPin className="h-4 w-4" />, label: "Location", value: item.location.name });
if (item.parentItem) facts.push({ icon: <Box className="h-4 w-4" />, label: "Inside", value: <Link className="hover:underline" href={`/inventory/${item.parentItem.id}`}>{item.parentItem.name}</Link> });
if (Number(item.quantity) !== 1) facts.push({ icon: <Package className="h-4 w-4" />, label: "Quantity", value: `${Number(item.quantity)}${item.unit ? ` ${item.unit}` : ""}` });
if (item.value != null) facts.push({ icon: <DollarSign className="h-4 w-4" />, label: "Value", value: Number(item.value).toLocaleString(undefined, { style: "currency", currency: "USD" }) });
if (item.purchaseDate) facts.push({ icon: <CalendarDays className="h-4 w-4" />, label: "Purchased", value: formatDate(item.purchaseDate) });
if (item.brand) facts.push({ icon: <Tag className="h-4 w-4" />, label: "Brand", value: item.brand });
if (item.modelNumber) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Model", value: item.modelNumber });
if (item.serial) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Serial", value: item.serial });
if (item.barcode) facts.push({ icon: <Barcode className="h-4 w-4" />, label: "Barcode", value: item.barcode });
return (
<div className="space-y-6 max-w-2xl">
<div className="flex items-center gap-3">
<Link href="/inventory" className="text-muted-foreground hover:text-foreground transition-colors">
<ChevronLeft className="h-5 w-5" />
</Link>
<div className="min-w-0">
<h1 className="font-display text-2xl font-semibold">{item.name}</h1>
{item.description && <p className="text-sm text-muted-foreground">{item.description}</p>}
</div>
{wStatus !== "none" && (
<Badge variant="outline" className="ml-auto shrink-0">{WARRANTY_LABELS[wStatus]}</Badge>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<EditItemButton item={editFields} locations={locations} items={allItems.filter((i) => i.id !== item.id)} labels={labels} />
<AddItemLogButton itemId={item.id} />
<AttachmentUpload itemId={item.id} />
<DeleteItemButton id={item.id} name={item.name} />
</div>
{item.labels.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{item.labels.map((l) => <Badge key={l.labelId} variant="secondary">{l.label.name}</Badge>)}
</div>
)}
{photos.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{photos.map((p) => (
// eslint-disable-next-line @next/next/no-img-element
<img key={p.id} src={p.url} alt={p.name ?? ""} className="h-28 w-full object-cover rounded-lg border" />
))}
</div>
)}
{facts.length > 0 && (
<Card>
<CardContent className="pt-4 grid grid-cols-2 gap-3 text-sm">
{facts.map((f, i) => (
<div key={i} className="flex items-start gap-2">
<span className="text-muted-foreground mt-0.5 shrink-0">{f.icon}</span>
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{f.label}</p>
<p className="truncate">{f.value}</p>
</div>
</div>
))}
</CardContent>
</Card>
)}
{item.notes && (
<Card><CardContent className="pt-4 text-sm whitespace-pre-wrap">{item.notes}</CardContent></Card>
)}
{docs.length > 0 && (
<div className="space-y-1.5">
<h2 className="font-display text-sm font-semibold">Documents</h2>
<div className="divide-y border rounded-lg overflow-hidden">
{docs.map((d) => (
<a key={d.id} href={d.url} target="_blank" rel="noreferrer"
className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent">
<FileText className="h-4 w-4 text-muted-foreground" />{d.name ?? d.kind}
<span className="ml-auto text-xs text-muted-foreground">{d.kind}</span>
</a>
))}
</div>
</div>
)}
{item.containedItems.length > 0 && (
<div className="space-y-1.5">
<h2 className="font-display text-lg font-semibold">Contains</h2>
<div className="divide-y border rounded-lg overflow-hidden">
{item.containedItems.map((c) => (
<Link key={c.id} href={`/inventory/${c.id}`} className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent">
<Package className="h-4 w-4 text-[hsl(var(--leaf))]" />{c.name}
</Link>
))}
</div>
</div>
)}
<div>
<h2 className="font-display text-lg font-semibold mb-3">History</h2>
{item.logs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm border rounded-lg">
No history yet log maintenance, a repair, or a move above.
</div>
) : (
<div className="space-y-2">
{item.logs.map((log) => (
<div key={log.id} className="flex items-start gap-3 p-3 rounded-lg border bg-card">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs font-semibold uppercase tracking-wide ${ITEM_LOG_COLORS[log.type]}`}>
{ITEM_LOG_LABELS[log.type]}
</span>
{log.cost != null && <span className="text-xs text-muted-foreground">· {Number(log.cost).toLocaleString(undefined, { style: "currency", currency: "USD" })}</span>}
<span className="text-xs text-muted-foreground ml-auto">{formatDate(log.date)}</span>
</div>
{log.notes && <p className="text-sm mt-1">{log.notes}</p>}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold">Inventory</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{items.length} {items.length === 1 ? "item" : "items"}
{total > 0 && ` · ${total.toLocaleString(undefined, { style: "currency", currency: "USD" })} total value`}
</p>
</div>
<AddItemButton locations={locations} items={itemOpts} labels={labels} />
</div>
<InventoryGrid items={gridItems} />
</div>
);
}

View File

@@ -10,7 +10,8 @@ export async function GET() {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); 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([ await Promise.all([
db.location.findMany(), db.location.findMany(),
db.plantType.findMany(), db.plantType.findMany(),
@@ -21,13 +22,18 @@ export async function GET() {
db.plantLog.findMany(), db.plantLog.findMany(),
db.task.findMany(), db.task.findMany(),
db.taskCompletion.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" } }), db.auditEvent.findMany({ orderBy: { createdAt: "asc" } }),
]); ]);
const manifest = { const manifest = {
version: 1, version: 1,
exportedAt: new Date().toISOString(), 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({ const zip = zipSync({
@@ -41,6 +47,11 @@ export async function GET() {
"plantLogs.json": strToU8(JSON.stringify(plantLogs, null, 2)), "plantLogs.json": strToU8(JSON.stringify(plantLogs, null, 2)),
"tasks.json": strToU8(JSON.stringify(tasks, null, 2)), "tasks.json": strToU8(JSON.stringify(tasks, null, 2)),
"taskCompletions.json": strToU8(JSON.stringify(taskCompletions, 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)), "auditEvents.json": strToU8(JSON.stringify(auditEvents, null, 2)),
}); });

View File

@@ -80,13 +80,23 @@ export async function POST(req: Request) {
const plantLogs = asArray("plantLogs.json"); const plantLogs = asArray("plantLogs.json");
const tasks = asArray("tasks.json"); const tasks = asArray("tasks.json");
const taskCompletions = asArray("taskCompletions.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"); const auditEvents = asArray("auditEvents.json");
// Restore inside a transaction — wipe then reload in FK order. // Restore inside a transaction — wipe then reload in FK order.
await db.$transaction(async (tx) => { await db.$transaction(async (tx) => {
await tx.taskCompletion.deleteMany(); await tx.taskCompletion.deleteMany();
await tx.plantLog.deleteMany(); await tx.plantLog.deleteMany();
await tx.itemLog.deleteMany();
await tx.itemAttachment.deleteMany();
await tx.labelOnItem.deleteMany();
await tx.task.deleteMany(); await tx.task.deleteMany();
await tx.item.deleteMany();
await tx.label.deleteMany();
await tx.plant.deleteMany(); await tx.plant.deleteMany();
await tx.plantGroup.deleteMany(); await tx.plantGroup.deleteMany();
await tx.plantType.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 (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups as never });
if (plants.length) await tx.plant.createMany({ data: plants 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 (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 (tasks.length) await tx.task.createMany({ data: tasks as never });
if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions as never }); if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions as never });
if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents as never }); if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents as never });

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 (
<>
<Button size="sm" onClick={() => setOpen(true)}>
<ClipboardList className="h-4 w-4 mr-1" /> Log
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader><DialogTitle>Log for this item</DialogTitle></DialogHeader>
<form onSubmit={submit} className="space-y-4 pt-1">
<div className="space-y-1.5">
<Label>Type</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ITEM_LOG_TYPES.map((t) => <SelectItem key={t} value={t}>{ITEM_LOG_LABELS[t]}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="What happened?" autoFocus />
</div>
<div className="space-y-1.5">
<Label>Cost ($, optional)</Label>
<Input type="number" min="0" step="0.01" value={cost} onChange={(e) => setCost(e.target.value)} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save log"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -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<HTMLInputElement>(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 (
<>
<input ref={inputRef} type="file" accept="image/*" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) onFile(f); e.target.value = ""; }} />
<Button size="sm" variant="outline" disabled={busy} onClick={() => inputRef.current?.click()}>
<ImagePlus className="h-4 w-4 mr-1" /> {busy ? "Uploading…" : "Add photo"}
</Button>
</>
);
}

View File

@@ -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 (
<>
<Button variant="outline" size="sm" className="text-[hsl(var(--ember))]" onClick={() => setOpen(true)}>
<Trash2 className="h-4 w-4 mr-1" /> Delete
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Delete &ldquo;{name}&rdquo;?</DialogTitle>
<DialogDescription>
The item is hidden but its history is kept. Anything stored inside it stays in the system.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleDelete} disabled={busy}
className="bg-[hsl(var(--ember))] hover:bg-[hsl(var(--ember)/.85)]">
{busy ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -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 <Badge variant="outline" className={`gap-1 text-[10px] ${map.cls}`}><Icon className="h-2.5 w-2.5" />{map.label}</Badge>;
}
function ItemRow({ item, childrenByParent, depth }: {
item: GridItem;
childrenByParent: Map<string, GridItem[]>;
depth: number;
}) {
const [open, setOpen] = useState(false);
const kids = childrenByParent.get(item.id) ?? [];
const qty = Number(item.quantity);
return (
<div>
<div className="flex items-center gap-2 px-3 py-2.5 hover:bg-accent transition-colors" style={{ paddingLeft: depth * 18 + 12 }}>
{kids.length > 0 ? (
<button onClick={() => setOpen((v) => !v)} className="text-muted-foreground shrink-0">
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : <span className="w-4 shrink-0" />}
{item.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.imageUrl} alt="" className="h-8 w-8 rounded object-cover shrink-0" />
) : (
<span className="h-8 w-8 rounded bg-[hsl(var(--leaf)/.08)] flex items-center justify-center shrink-0">
{kids.length > 0 ? <Box className="h-4 w-4 text-[hsl(var(--leaf)/.5)]" /> : <Package className="h-4 w-4 text-[hsl(var(--leaf)/.5)]" />}
</span>
)}
<Link href={`/inventory/${item.id}`} className="min-w-0 flex-1">
<span className="font-medium text-sm">{item.name}</span>
{qty !== 1 && <span className="text-xs text-muted-foreground"> · {qty}{item.unit ? ` ${item.unit}` : ""}</span>}
{kids.length > 0 && <span className="text-xs text-muted-foreground"> · holds {kids.length}</span>}
</Link>
<div className="flex items-center gap-1.5 shrink-0">
{item.labels.slice(0, 3).map((l) => (
<Badge key={l.label.id} variant="secondary" className="text-[10px]">{l.label.name}</Badge>
))}
<WarrantyBadge expiry={item.warrantyExpiry} />
</div>
</div>
{open && kids.map((k) => (
<ItemRow key={k.id} item={k} childrenByParent={childrenByParent} depth={depth + 1} />
))}
</div>
);
}
export function InventoryGrid({ items }: { items: GridItem[] }) {
if (items.length === 0) {
return (
<div className="text-center py-16 text-muted-foreground">
<Package className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="font-medium">No items yet</p>
<p className="text-sm mt-1">Add your first tool, gadget, or piece of gear.</p>
</div>
);
}
// Items contained inside another item are rendered under their parent.
const childrenByParent = new Map<string, GridItem[]>();
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<string, { name: string; items: GridItem[] }>();
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 (
<div className="space-y-6">
{sorted.map((g) => (
<div key={g.name} className="space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium">
<MapPin className="h-4 w-4 text-[hsl(var(--leaf))]" />
<span className="font-display">{g.name}</span>
<span className="text-xs text-muted-foreground font-normal">· {g.items.length}</span>
</div>
<div className="divide-y border rounded-lg overflow-hidden">
{g.items.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
</div>
</div>
))}
{noLoc.length > 0 && (
<div className="space-y-1.5">
{sorted.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location</p>}
<div className="divide-y border rounded-lg overflow-hidden">
{noLoc.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
</div>
</div>
)}
</div>
);
}

View File

@@ -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<ItemFields>(initial);
const [labels, setLabels] = useState<LabelOpt[]>(initialLabels);
const [newLabel, setNewLabel] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const router = useRouter();
const { toast } = useToast();
function set<K extends keyof ItemFields>(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 (
<>
<span onClick={() => { setF(initial); setLabels(initialLabels); setOpen(true); }}>{trigger}</span>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1.5">
<Label>Name *</Label>
<Input value={f.name} onChange={(e) => set("name", e.target.value)} placeholder="Cordless drill" />
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<div className="space-y-1.5">
<Label>Quantity</Label>
<Input type="number" min="0" step="any" value={f.quantity} onChange={(e) => set("quantity", e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Unit</Label>
<Input value={f.unit} onChange={(e) => set("unit", e.target.value)} placeholder="each" />
</div>
<div className="space-y-1.5">
<Label>Location</Label>
<Select value={f.locationId || NONE} onValueChange={(v) => set("locationId", v === NONE ? "" : v)}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}></SelectItem>
{locations.map((l) => <SelectItem key={l.id} value={l.id}>{l.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Inside item</Label>
<Select value={f.parentItemId || NONE} onValueChange={(v) => set("parentItemId", v === NONE ? "" : v)}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}> (not inside another item)</SelectItem>
{parentOptions.map((i) => <SelectItem key={i.id} value={i.id}>{i.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Brand</Label>
<Input value={f.brand} onChange={(e) => set("brand", e.target.value)} placeholder="DeWalt" />
</div>
<div className="space-y-1.5">
<Label>Model #</Label>
<Input value={f.modelNumber} onChange={(e) => set("modelNumber", e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Serial #</Label>
<Input value={f.serial} onChange={(e) => set("serial", e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Value ($)</Label>
<Input type="number" min="0" step="0.01" value={f.value} onChange={(e) => set("value", e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Purchased</Label>
<Input type="date" value={f.purchaseDate} onChange={(e) => set("purchaseDate", e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Warranty until</Label>
<Input type="date" value={f.warrantyExpiry} onChange={(e) => set("warrantyExpiry", e.target.value)} />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Labels</Label>
<div className="flex flex-wrap gap-1.5">
{labels.map((l) => (
<button key={l.id} type="button" onClick={() => toggleLabel(l.id)}>
<Badge variant={f.labelIds.includes(l.id) ? "default" : "outline"} className="cursor-pointer">
{l.name}
</Badge>
</button>
))}
</div>
<div className="flex gap-2 mt-1">
<Input value={newLabel} onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addLabel(); } }}
placeholder="New label…" className="h-8 text-sm" />
<Button type="button" size="sm" variant="outline" onClick={addLabel}>Add</Button>
</div>
</div>
<div className="col-span-2 space-y-1.5">
<Label>Notes</Label>
<Textarea rows={2} value={f.notes} onChange={(e) => set("notes", e.target.value)} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}
export function AddItemButton(props: { locations: LocationOpt[]; items: ItemOpt[]; labels: LabelOpt[] }) {
return (
<ItemDialog title="Add an item" initial={blankItem()} {...props}
trigger={<Button size="sm"><Plus className="h-4 w-4 mr-1" />Add item</Button>} />
);
}
export function EditItemButton(props: { item: ItemFields; locations: LocationOpt[]; items: ItemOpt[]; labels: LabelOpt[] }) {
const { item, ...rest } = props;
return (
<ItemDialog title="Edit item" initial={item} {...rest}
trigger={<Button variant="outline" size="sm"><Pencil className="h-4 w-4 mr-1" />Edit</Button>} />
);
}

View File

@@ -2,13 +2,14 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; 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"; import { cn } from "@/lib/utils";
const navItems = [ const navItems = [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard }, { label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ label: "Garden", href: "/garden", icon: Leaf }, { label: "Garden", href: "/garden", icon: Leaf },
{ label: "Plant types", href: "/garden/types", icon: BookOpen }, { label: "Plant types", href: "/garden/types", icon: BookOpen },
{ label: "Inventory", href: "/inventory", icon: Package },
{ label: "Reminders", href: "/tasks", icon: Bell }, { label: "Reminders", href: "/tasks", icon: Bell },
// Home maintenance and Equipment come in future releases // Home maintenance and Equipment come in future releases
// { label: "Home", href: "/home", icon: Home }, // { label: "Home", href: "/home", icon: Home },

View File

@@ -22,6 +22,40 @@ export const taskCreateInput = z.object({
export const taskCompleteInput = z.object({ notes: z.string().optional() }); 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<typeof plantLogInput>; export type PlantLogInput = z.infer<typeof plantLogInput>;
export type TaskCreateInput = z.infer<typeof taskCreateInput>; export type TaskCreateInput = z.infer<typeof taskCreateInput>;
export type TaskCompleteInput = z.infer<typeof taskCompleteInput>; export type TaskCompleteInput = z.infer<typeof taskCompleteInput>;
export type ItemCreateInput = z.infer<typeof itemCreateInput>;
export type ItemUpdateInput = z.infer<typeof itemUpdateInput>;
export type ItemLogInput = z.infer<typeof itemLogInput>;

View File

@@ -8,6 +8,13 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: 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", version: "0.12.0",
date: "2026-06-15", date: "2026-06-15",

View File

@@ -0,0 +1,26 @@
import { ItemLogType } from "@prisma/client";
export const ITEM_LOG_LABELS: Record<ItemLogType, string> = {
NOTE: "Note",
MAINTENANCE: "Maintenance",
MOVED: "Moved",
PURCHASED: "Purchased",
REPAIRED: "Repaired",
DISPOSED: "Disposed",
};
export const ITEM_LOG_COLORS: Record<ItemLogType, string> = {
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];

View File

@@ -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);
});
});

View File

@@ -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<WarrantyStatus, string> = {
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);
}

173
src/lib/services/items.ts Normal file
View File

@@ -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<boolean> {
let cur: string | null = candidateParentId;
const seen = new Set<string>();
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;
}

View File

@@ -1,2 +1,2 @@
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`. // 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";