From 5f4c482636c2c00b6bfa4f2ddf289efabed26fc9 Mon Sep 17 00:00:00 2001 From: tonym Date: Tue, 16 Jun 2026 01:01:17 -0500 Subject: [PATCH] =?UTF-8?q?v0.16.0=20=E2=80=94=20HomeBox=20CSV=20import=20?= =?UTF-8?q?+=20sold=20lifecycle=20+=20custom=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring an existing HomeBox inventory straight in, and capture the fields HomeBox has that we were missing (skipping asset IDs per Tony). - Item: soldTo/soldPrice/soldDate/soldNotes + flexible customFields (JSON, e.g. VIN / oil weight for vehicles); both editable in the item dialog and shown on the detail page - Pure HomeBox CSV parser (src/lib/inventory/homebox-import.ts, +5 tests) — RFC4180 quoting (commas/newlines/escaped quotes in fields), HomeBox's 0001-* null dates and 0 = empty price, semicolon labels, HB.field.* custom columns, nested 'A / B' location paths - Import service + /api/v1/items/import: find-or-creates the nested locations and labels, creates items, recomputes location paths, returns a summary - Inventory 'Import' button: paste/upload CSV, review every row with checkboxes (uncheck to skip), then import. Preview parses client-side (parser is pure) - Verified end-to-end: nested locations, split labels, sold + custom fields, skip, quoted names, null-date handling. tsc + 38 tests + build green Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migration.sql | 6 + prisma/schema.prisma | 9 ++ src/app/(dashboard)/inventory/[id]/page.tsx | 32 ++++ src/app/(dashboard)/inventory/page.tsx | 6 +- src/app/api/v1/items/import/route.ts | 22 +++ src/components/inventory/import-button.tsx | 134 +++++++++++++++++ src/components/inventory/item-dialog.tsx | 38 +++++ src/lib/api/schemas.ts | 5 + src/lib/changelog.ts | 8 + src/lib/inventory/homebox-import.test.ts | 61 ++++++++ src/lib/inventory/homebox-import.ts | 131 ++++++++++++++++ src/lib/services/inventory-import.ts | 141 ++++++++++++++++++ src/lib/services/items.ts | 11 ++ src/lib/version.ts | 2 +- 14 files changed, 604 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260616055215_v0_16_item_sold_custom/migration.sql create mode 100644 src/app/api/v1/items/import/route.ts create mode 100644 src/components/inventory/import-button.tsx create mode 100644 src/lib/inventory/homebox-import.test.ts create mode 100644 src/lib/inventory/homebox-import.ts create mode 100644 src/lib/services/inventory-import.ts diff --git a/prisma/migrations/20260616055215_v0_16_item_sold_custom/migration.sql b/prisma/migrations/20260616055215_v0_16_item_sold_custom/migration.sql new file mode 100644 index 0000000..f203b11 --- /dev/null +++ b/prisma/migrations/20260616055215_v0_16_item_sold_custom/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Item" ADD COLUMN "customFields" JSONB, +ADD COLUMN "soldDate" TIMESTAMP(3), +ADD COLUMN "soldNotes" TEXT, +ADD COLUMN "soldPrice" DECIMAL(10,2), +ADD COLUMN "soldTo" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0f67931..c34491c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -325,6 +325,15 @@ model Item { modelNumber String? brand String? // brand / manufacturer + // Sold lifecycle (HomeBox parity) + soldTo String? + soldPrice Decimal? @db.Decimal(10, 2) + soldDate DateTime? + soldNotes String? + + // Custom fields — flexible key/value (e.g. VIN, "Oil Weight": "5W-30") + customFields Json? + // Consumable attributes (Pantry phase) barcode String? bestBefore DateTime? diff --git a/src/app/(dashboard)/inventory/[id]/page.tsx b/src/app/(dashboard)/inventory/[id]/page.tsx index a59b2a7..cdfdfb9 100644 --- a/src/app/(dashboard)/inventory/[id]/page.tsx +++ b/src/app/(dashboard)/inventory/[id]/page.tsx @@ -70,6 +70,11 @@ export default async function ItemDetailPage({ params }: { params: { id: string warrantyDetails: item.warrantyDetails ?? "", lifetimeWarranty: item.lifetimeWarranty, insured: item.insured, + soldTo: item.soldTo ?? "", + soldPrice: item.soldPrice != null ? String(Number(item.soldPrice)) : "", + soldDate: toDateInput(item.soldDate), + soldNotes: item.soldNotes ?? "", + customFields: Object.entries((item.customFields as Record) ?? {}).map(([key, value]) => ({ key, value: String(value) })), barcode: item.barcode ?? "", notes: item.notes ?? "", labelIds: item.labels.map((l) => l.labelId), @@ -156,6 +161,33 @@ export default async function ItemDetailPage({ params }: { params: { id: string )} + {item.customFields && Object.keys(item.customFields as Record).length > 0 && ( + + + {Object.entries(item.customFields as Record).map(([k, v]) => ( +
+

{k}

+

{String(v)}

+
+ ))} +
+
+ )} + + {item.soldTo && ( + + +

Sold

+

+ To {item.soldTo} + {item.soldPrice != null && ` for ${Number(item.soldPrice).toLocaleString(undefined, { style: "currency", currency: "USD" })}`} + {item.soldDate && ` on ${formatDate(item.soldDate)}`} +

+ {item.soldNotes &&

{item.soldNotes}

} +
+
+ )} +

Maintenance

diff --git a/src/app/(dashboard)/inventory/page.tsx b/src/app/(dashboard)/inventory/page.tsx index 2734b8e..7617c1c 100644 --- a/src/app/(dashboard)/inventory/page.tsx +++ b/src/app/(dashboard)/inventory/page.tsx @@ -1,5 +1,6 @@ import { db } from "@/lib/db"; import { AddItemButton } from "@/components/inventory/item-dialog"; +import { ImportButton } from "@/components/inventory/import-button"; import { InventoryGrid, type GridItem } from "@/components/inventory/inventory-grid"; import { totalValue } from "@/lib/inventory/value"; @@ -53,7 +54,10 @@ export default async function InventoryPage() { {total > 0 && ` · ${total.toLocaleString(undefined, { style: "currency", currency: "USD" })} total value`}

- +
+ + +
diff --git a/src/app/api/v1/items/import/route.ts b/src/app/api/v1/items/import/route.ts new file mode 100644 index 0000000..4bc7718 --- /dev/null +++ b/src/app/api/v1/items/import/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { authenticateRequest, handleApiError } from "@/lib/api/authenticate"; +import { importHomeBoxItems } from "@/lib/services/inventory-import"; + +const schema = z.object({ + csv: z.string().min(1), + skip: z.array(z.string()).optional(), +}); + +// POST /api/v1/items/import — import a HomeBox CSV export. `skip` lists item +// names to leave out (e.g. the ones unchecked in the preview). +export async function POST(req: Request) { + try { + const ctx = await authenticateRequest(req, "items:write"); + const { csv, skip } = schema.parse(await req.json()); + const summary = await importHomeBoxItems(ctx, { csv, skip }); + return NextResponse.json(summary, { status: 201 }); + } catch (err) { + return handleApiError(err); + } +} diff --git a/src/components/inventory/import-button.tsx b/src/components/inventory/import-button.tsx new file mode 100644 index 0000000..3956ff6 --- /dev/null +++ b/src/components/inventory/import-button.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { useToast } from "@/hooks/use-toast"; +import { Upload, FileUp } from "lucide-react"; +import { parseHomeBoxCsv, type HomeBoxItem } from "@/lib/inventory/homebox-import"; + +export function ImportButton() { + const [open, setOpen] = useState(false); + const [csv, setCsv] = useState(""); + const [rows, setRows] = useState(null); + const [skip, setSkip] = useState>(new Set()); + const [busy, setBusy] = useState(false); + const inputRef = useRef(null); + const router = useRouter(); + const { toast } = useToast(); + + function preview(text: string) { + setCsv(text); + try { + const parsed = parseHomeBoxCsv(text); + setRows(parsed); + setSkip(new Set()); + if (parsed.length === 0) toast({ title: "Nothing found", description: "No items parsed from that CSV.", variant: "destructive" }); + } catch { + toast({ title: "Parse error", description: "Couldn't read that CSV.", variant: "destructive" }); + } + } + + function toggle(name: string) { + setSkip((prev) => { + const next = new Set(prev); + next.has(name) ? next.delete(name) : next.add(name); + return next; + }); + } + + async function doImport() { + if (!rows) return; + const include = rows.filter((r) => !skip.has(r.name)); + if (include.length === 0) { toast({ title: "Nothing selected" }); return; } + setBusy(true); + const res = await fetch("/api/v1/items/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ csv, skip: Array.from(skip) }), + }); + setBusy(false); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + toast({ title: "Import failed", description: err.error ?? "Try again.", variant: "destructive" }); + return; + } + const s = await res.json(); + toast({ title: "Imported!", description: `${s.created} items, ${s.locationsCreated} locations, ${s.labelsCreated} labels.` }); + setOpen(false); setCsv(""); setRows(null); setSkip(new Set()); + router.refresh(); + } + + const includeCount = rows ? rows.length - skip.size : 0; + + return ( + <> + + { setOpen(v); if (!v) { setRows(null); setCsv(""); } }}> + + Import from HomeBox CSV + + {!rows ? ( +
+

+ Paste a HomeBox CSV export, or choose a file. You'll review every item before importing. +

+