From 63569aabd335158d057d63e0cf23b9983a7e77c7 Mon Sep 17 00:00:00 2001 From: tonym Date: Tue, 16 Jun 2026 00:34:07 -0500 Subject: [PATCH] =?UTF-8?q?v0.15.0=20=E2=80=94=20Inventory=20depth=20(Home?= =?UTF-8?q?Box=20parity)=20+=20Locations=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rounds out Inventory toward HomeBox, and gives Locations a real home. Inventory: - Attach manuals, receipts, and warranty docs (any file), not just photos — attachment uploader now has a kind picker; WARRANTY kind added - Richer purchase/warranty: purchasedFrom, warrantyDetails, lifetimeWarranty, insured (item dialog + detail facts + header badge) - Schedule recurring maintenance from an item (creates a Task linked via itemId); item detail shows its maintenance with overdue flagged. Task create now accepts itemId/animalId Locations: - New Locations page + nav: browse/add/rename/move/delete the whole place tree (LocationManager over the existing /api/locations routes); shows plant/item counts per place. The spot to merge near-dupes before dropping legacy zone Verified end-to-end against a running server (new fields round-trip, maintenance task links to item, MANUAL attachment stored). tsc + 33 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migration.sql | 5 + prisma/schema.prisma | 16 +- src/app/(dashboard)/inventory/[id]/page.tsx | 57 +++++- src/app/(dashboard)/locations/page.tsx | 35 ++++ .../api/v1/items/[id]/attachments/route.ts | 2 +- .../inventory/attachment-upload.tsx | 42 ++++- src/components/inventory/item-dialog.tsx | 32 +++- .../inventory/schedule-maintenance-button.tsx | 108 +++++++++++ src/components/layout/sidebar.tsx | 3 +- src/components/locations/location-manager.tsx | 176 ++++++++++++++++++ src/lib/api/schemas.ts | 6 + src/lib/changelog.ts | 8 + src/lib/services/items.ts | 8 + src/lib/services/tasks.ts | 2 + src/lib/version.ts | 2 +- 15 files changed, 481 insertions(+), 21 deletions(-) create mode 100644 prisma/migrations/20260616052619_v0_15_item_details/migration.sql create mode 100644 src/app/(dashboard)/locations/page.tsx create mode 100644 src/components/inventory/schedule-maintenance-button.tsx create mode 100644 src/components/locations/location-manager.tsx diff --git a/prisma/migrations/20260616052619_v0_15_item_details/migration.sql b/prisma/migrations/20260616052619_v0_15_item_details/migration.sql new file mode 100644 index 0000000..0e3caad --- /dev/null +++ b/prisma/migrations/20260616052619_v0_15_item_details/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Item" ADD COLUMN "insured" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "lifetimeWarranty" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "purchaseFrom" TEXT, +ADD COLUMN "warrantyDetails" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cce618d..0f67931 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -314,12 +314,16 @@ model Item { containedItems Item[] @relation("ItemNesting") // Durable attributes - value Decimal? @db.Decimal(10, 2) - purchaseDate DateTime? - warrantyExpiry DateTime? - serial String? - modelNumber String? - brand String? + value Decimal? @db.Decimal(10, 2) // purchase price + purchaseDate DateTime? + purchaseFrom String? // where it was bought + warrantyExpiry DateTime? + warrantyDetails String? // freeform warranty terms + lifetimeWarranty Boolean @default(false) + insured Boolean @default(false) + serial String? + modelNumber String? + brand String? // brand / manufacturer // Consumable attributes (Pantry phase) barcode String? diff --git a/src/app/(dashboard)/inventory/[id]/page.tsx b/src/app/(dashboard)/inventory/[id]/page.tsx index 72bdf01..a59b2a7 100644 --- a/src/app/(dashboard)/inventory/[id]/page.tsx +++ b/src/app/(dashboard)/inventory/[id]/page.tsx @@ -5,13 +5,16 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { ChevronLeft, MapPin, Box, Package, CalendarDays, DollarSign, Hash, Tag, FileText, Barcode, + Store, ShieldCheck, Wrench, AlertCircle, } 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 { isOverdue, describeRecurrence } from "@/lib/tasks/schedule"; 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 { ScheduleMaintenanceButton } from "@/components/inventory/schedule-maintenance-button"; import { AttachmentUpload } from "@/components/inventory/attachment-upload"; function toDateInput(d: Date | null): string { @@ -34,6 +37,7 @@ export default async function ItemDetailPage({ params }: { params: { id: string attachments: { orderBy: { createdAt: "asc" } }, logs: { orderBy: { date: "desc" } }, containedItems: { where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }, + tasks: { where: { active: true }, orderBy: { nextDue: "asc" } }, }, }), db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }), @@ -43,7 +47,8 @@ export default async function ItemDetailPage({ params }: { params: { id: string if (!item || !item.active) notFound(); - const wStatus = warrantyStatus(item.warrantyExpiry, new Date()); + const wStatus = item.lifetimeWarranty ? "active" : warrantyStatus(item.warrantyExpiry, new Date()); + const warrantyLabel = item.lifetimeWarranty ? "Lifetime warranty" : WARRANTY_LABELS[wStatus]; const photos = item.attachments.filter((a) => a.kind === "PHOTO"); const docs = item.attachments.filter((a) => a.kind !== "PHOTO"); @@ -60,7 +65,11 @@ export default async function ItemDetailPage({ params }: { params: { id: string serial: item.serial ?? "", value: item.value != null ? String(Number(item.value)) : "", purchaseDate: toDateInput(item.purchaseDate), + purchaseFrom: item.purchaseFrom ?? "", warrantyExpiry: toDateInput(item.warrantyExpiry), + warrantyDetails: item.warrantyDetails ?? "", + lifetimeWarranty: item.lifetimeWarranty, + insured: item.insured, barcode: item.barcode ?? "", notes: item.notes ?? "", labelIds: item.labels.map((l) => l.labelId), @@ -72,6 +81,9 @@ export default async function ItemDetailPage({ params }: { params: { id: string 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.purchaseFrom) facts.push({ icon: , label: "From", value: item.purchaseFrom }); + if (item.lifetimeWarranty || item.warrantyExpiry) facts.push({ icon: , label: "Warranty", value: item.lifetimeWarranty ? "Lifetime" : `until ${formatDate(item.warrantyExpiry!)}` }); + if (item.insured) facts.push({ icon: , label: "Insured", value: "Yes" }); 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 }); @@ -87,14 +99,15 @@ export default async function ItemDetailPage({ params }: { params: { id: string

{item.name}

{item.description &&

{item.description}

} - {wStatus !== "none" && ( - {WARRANTY_LABELS[wStatus]} + {(item.lifetimeWarranty || wStatus !== "none") && ( + {warrantyLabel} )}
i.id !== item.id)} labels={labels} /> +
@@ -134,6 +147,44 @@ export default async function ItemDetailPage({ params }: { params: { id: string {item.notes} )} + {item.warrantyDetails && ( + + +

Warranty

+

{item.warrantyDetails}

+
+
+ )} + +
+
+

Maintenance

+
+ {item.tasks.length === 0 ? ( +

+ No maintenance scheduled. Use “Schedule maintenance” above to set a recurring reminder. +

+ ) : ( +
+ {item.tasks.map((t) => { + const overdue = isOverdue(t.nextDue); + return ( +
+ + {t.title} + · {describeRecurrence(t.recurrence, t.intervalDays, t.month)} + + + {overdue && } + {overdue ? "Overdue " : "Due "}{formatDate(t.nextDue)} + +
+ ); + })} +
+ )} +
+ {docs.length > 0 && (

Documents

diff --git a/src/app/(dashboard)/locations/page.tsx b/src/app/(dashboard)/locations/page.tsx new file mode 100644 index 0000000..d1ed4f3 --- /dev/null +++ b/src/app/(dashboard)/locations/page.tsx @@ -0,0 +1,35 @@ +import { db } from "@/lib/db"; +import { LocationManager } from "@/components/locations/location-manager"; + +export const metadata = { title: "Locations" }; + +export default async function LocationsPage() { + const locations = await db.location.findMany({ + where: { active: true }, + orderBy: { name: "asc" }, + select: { + id: true, + name: true, + kind: true, + parentId: true, + _count: { + select: { + plants: { where: { active: true } }, + items: { where: { active: true } }, + }, + }, + }, + }); + + return ( +
+
+

Locations

+

+ Manage the places on your property — nest them as deep as you like. +

+
+ +
+ ); +} diff --git a/src/app/api/v1/items/[id]/attachments/route.ts b/src/app/api/v1/items/[id]/attachments/route.ts index 9565902..0c4129c 100644 --- a/src/app/api/v1/items/[id]/attachments/route.ts +++ b/src/app/api/v1/items/[id]/attachments/route.ts @@ -6,7 +6,7 @@ import { authenticateRequest, handleApiError, ApiError } from "@/lib/api/authent export const dynamic = "force-dynamic"; -const KINDS = new Set(["PHOTO", "MANUAL", "RECEIPT"]); +const KINDS = new Set(["PHOTO", "MANUAL", "RECEIPT", "WARRANTY"]); // POST /api/v1/items/:id/attachments — multipart upload (file, kind). Files land // under public/uploads/items (a persistent Docker volume), same as suggestions. diff --git a/src/components/inventory/attachment-upload.tsx b/src/components/inventory/attachment-upload.tsx index 36a8532..7ec9383 100644 --- a/src/components/inventory/attachment-upload.tsx +++ b/src/components/inventory/attachment-upload.tsx @@ -3,33 +3,61 @@ import { useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { useToast } from "@/hooks/use-toast"; -import { ImagePlus } from "lucide-react"; +import { Paperclip, ChevronDown } from "lucide-react"; + +const KINDS: { value: string; label: string; accept: string }[] = [ + { value: "PHOTO", label: "Photo", accept: "image/*" }, + { value: "MANUAL", label: "Manual", accept: ".pdf,image/*" }, + { value: "RECEIPT", label: "Receipt", accept: ".pdf,image/*" }, + { value: "WARRANTY", label: "Warranty doc", accept: ".pdf,image/*" }, +]; export function AttachmentUpload({ itemId }: { itemId: string }) { const [busy, setBusy] = useState(false); + const [kind, setKind] = useState("PHOTO"); const inputRef = useRef(null); const router = useRouter(); const { toast } = useToast(); + function pick(k: string) { + setKind(k); + // Let state settle, then open the picker with the right accept filter. + requestAnimationFrame(() => inputRef.current?.click()); + } + async function onFile(file: File) { setBusy(true); const form = new FormData(); form.append("file", file); - form.append("kind", "PHOTO"); + form.append("kind", kind); const res = await fetch(`/api/v1/items/${itemId}/attachments`, { method: "POST", body: form }); setBusy(false); - if (res.ok) { toast({ title: "Photo added" }); router.refresh(); } + if (res.ok) { toast({ title: "Attached" }); router.refresh(); } else toast({ title: "Error", description: "Upload failed.", variant: "destructive" }); } + const accept = KINDS.find((k) => k.value === kind)?.accept ?? "*"; + return ( <> - { const f = e.target.files?.[0]; if (f) onFile(f); e.target.value = ""; }} /> - + + + + + + {KINDS.map((k) => ( + pick(k.value)}>{k.label} + ))} + + ); } diff --git a/src/components/inventory/item-dialog.tsx b/src/components/inventory/item-dialog.tsx index 85fac6d..1f6901d 100644 --- a/src/components/inventory/item-dialog.tsx +++ b/src/components/inventory/item-dialog.tsx @@ -33,7 +33,11 @@ export type ItemFields = { serial: string; value: string; purchaseDate: string; + purchaseFrom: string; warrantyExpiry: string; + warrantyDetails: string; + lifetimeWarranty: boolean; + insured: boolean; barcode: string; notes: string; labelIds: string[]; @@ -42,7 +46,8 @@ export type ItemFields = { export function blankItem(): ItemFields { return { name: "", description: "", quantity: "1", unit: "", locationId: "", parentItemId: "", - brand: "", modelNumber: "", serial: "", value: "", purchaseDate: "", warrantyExpiry: "", + brand: "", modelNumber: "", serial: "", value: "", purchaseDate: "", purchaseFrom: "", + warrantyExpiry: "", warrantyDetails: "", lifetimeWarranty: false, insured: false, barcode: "", notes: "", labelIds: [], }; } @@ -109,7 +114,11 @@ function ItemDialog({ serial: f.serial.trim() || undefined, value: f.value ? Number(f.value) : null, purchaseDate: f.purchaseDate || null, + purchaseFrom: f.purchaseFrom.trim() || undefined, warrantyExpiry: f.warrantyExpiry || null, + warrantyDetails: f.warrantyDetails.trim() || undefined, + lifetimeWarranty: f.lifetimeWarranty, + insured: f.insured, barcode: f.barcode.trim() || undefined, notes: f.notes.trim() || undefined, labelIds: f.labelIds, @@ -197,7 +206,26 @@ function ItemDialog({
- set("warrantyExpiry", e.target.value)} /> + set("warrantyExpiry", e.target.value)} disabled={f.lifetimeWarranty} /> +
+ +
+ + set("purchaseFrom", e.target.value)} placeholder="Home Depot, Amazon, gift…" /> +
+
+ +