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. +

+