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:
143
prisma/migrations/20260616050040_v0_13_inventory/migration.sql
Normal file
143
prisma/migrations/20260616050040_v0_13_inventory/migration.sql
Normal 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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
192
src/app/(dashboard)/inventory/[id]/page.tsx
Normal file
192
src/app/(dashboard)/inventory/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
src/app/(dashboard)/inventory/page.tsx
Normal file
62
src/app/(dashboard)/inventory/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)),
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
49
src/app/api/v1/items/[id]/attachments/route.ts
Normal file
49
src/app/api/v1/items/[id]/attachments/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
15
src/app/api/v1/items/[id]/logs/route.ts
Normal file
15
src/app/api/v1/items/[id]/logs/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
36
src/app/api/v1/items/[id]/route.ts
Normal file
36
src/app/api/v1/items/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
34
src/app/api/v1/items/route.ts
Normal file
34
src/app/api/v1/items/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
37
src/app/api/v1/labels/route.ts
Normal file
37
src/app/api/v1/labels/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
78
src/components/inventory/add-item-log-button.tsx
Normal file
78
src/components/inventory/add-item-log-button.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
35
src/components/inventory/attachment-upload.tsx
Normal file
35
src/components/inventory/attachment-upload.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
57
src/components/inventory/delete-item-button.tsx
Normal file
57
src/components/inventory/delete-item-button.tsx
Normal 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 “{name}”?</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
145
src/components/inventory/inventory-grid.tsx
Normal file
145
src/components/inventory/inventory-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
252
src/components/inventory/item-dialog.tsx
Normal file
252
src/components/inventory/item-dialog.tsx
Normal 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>} />
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw, BookOpen, KeyRound } from "lucide-react";
|
||||
import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw, BookOpen, KeyRound, Package } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
||||
{ label: "Garden", href: "/garden", icon: Leaf },
|
||||
{ label: "Plant types", href: "/garden/types", icon: BookOpen },
|
||||
{ label: "Inventory", href: "/inventory", icon: Package },
|
||||
{ label: "Reminders", href: "/tasks", icon: Bell },
|
||||
// Home maintenance and Equipment come in future releases
|
||||
// { label: "Home", href: "/home", icon: Home },
|
||||
|
||||
@@ -22,6 +22,40 @@ export const taskCreateInput = z.object({
|
||||
|
||||
export const taskCompleteInput = z.object({ notes: z.string().optional() });
|
||||
|
||||
// --- Inventory ---
|
||||
|
||||
export const itemCreateInput = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
kind: z.enum(["DURABLE", "CONSUMABLE"]).default("DURABLE"),
|
||||
quantity: z.coerce.number().nonnegative().optional(),
|
||||
unit: z.string().optional(),
|
||||
locationId: z.string().nullable().optional(),
|
||||
parentItemId: z.string().nullable().optional(),
|
||||
value: z.coerce.number().nonnegative().nullable().optional(),
|
||||
purchaseDate: z.string().nullable().optional(),
|
||||
warrantyExpiry: z.string().nullable().optional(),
|
||||
serial: z.string().optional(),
|
||||
modelNumber: z.string().optional(),
|
||||
brand: z.string().optional(),
|
||||
barcode: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
imageUrl: z.string().nullable().optional(),
|
||||
labelIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const itemUpdateInput = itemCreateInput.partial();
|
||||
|
||||
export const itemLogInput = z.object({
|
||||
type: z.enum(["NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED"]),
|
||||
notes: z.string().optional(),
|
||||
cost: z.coerce.number().nonnegative().nullable().optional(),
|
||||
date: z.string().optional(),
|
||||
});
|
||||
|
||||
export type PlantLogInput = z.infer<typeof plantLogInput>;
|
||||
export type TaskCreateInput = z.infer<typeof taskCreateInput>;
|
||||
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>;
|
||||
|
||||
@@ -8,6 +8,13 @@ export type ChangelogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.13.0",
|
||||
date: "2026-06-16",
|
||||
changes: [
|
||||
"New Inventory section — keep track of the things you own: tools, gear, equipment. Record where each one lives, its value, brand, serial number, and warranty (you'll get a heads-up when one's expiring). Items can hold other items, so a toolbox shows the tools inside it. Add photos, labels to group things, and a maintenance/repair history per item.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.12.0",
|
||||
date: "2026-06-15",
|
||||
|
||||
26
src/lib/inventory/item-fields.ts
Normal file
26
src/lib/inventory/item-fields.ts
Normal 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];
|
||||
46
src/lib/inventory/value.test.ts
Normal file
46
src/lib/inventory/value.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
39
src/lib/inventory/value.ts
Normal file
39
src/lib/inventory/value.ts
Normal 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
173
src/lib/services/items.ts
Normal 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;
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
|
||||
export const APP_VERSION = "0.12.0";
|
||||
export const APP_VERSION = "0.13.0";
|
||||
|
||||
Reference in New Issue
Block a user