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

View File

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

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