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 + {p.name + ))} +
+ )} + + {facts.length > 0 && ( + + + {facts.map((f, i) => ( +
+ {f.icon} +
+

{f.label}

+

{f.value}

+
+
+ ))} +
+
+ )} + + {item.notes && ( + {item.notes} + )} + + {docs.length > 0 && ( +
+

Documents

+
+ {docs.map((d) => ( + + {d.name ?? d.kind} + {d.kind} + + ))} +
+
+ )} + + {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 ( + <> + + + + Log for this item +
+
+ + +
+
+ +