v0.16.0 — HomeBox CSV import + sold lifecycle + custom fields
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
@@ -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?
|
||||
|
||||
@@ -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<string, string>) ?? {}).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
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{item.customFields && Object.keys(item.customFields as Record<string, string>).length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
{Object.entries(item.customFields as Record<string, string>).map(([k, v]) => (
|
||||
<div key={k} className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{k}</p>
|
||||
<p className="truncate">{String(v)}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{item.soldTo && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 text-sm space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Sold</p>
|
||||
<p>
|
||||
To {item.soldTo}
|
||||
{item.soldPrice != null && ` for ${Number(item.soldPrice).toLocaleString(undefined, { style: "currency", currency: "USD" })}`}
|
||||
{item.soldDate && ` on ${formatDate(item.soldDate)}`}
|
||||
</p>
|
||||
{item.soldNotes && <p className="text-muted-foreground">{item.soldNotes}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-display text-lg font-semibold">Maintenance</h2>
|
||||
|
||||
@@ -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`}
|
||||
</p>
|
||||
</div>
|
||||
<AddItemButton locations={locations} items={itemOpts} labels={labels} />
|
||||
<div className="flex items-center gap-2">
|
||||
<ImportButton />
|
||||
<AddItemButton locations={locations} items={itemOpts} labels={labels} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InventoryGrid items={gridItems} />
|
||||
|
||||
22
src/app/api/v1/items/import/route.ts
Normal file
22
src/app/api/v1/items/import/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
134
src/components/inventory/import-button.tsx
Normal file
134
src/components/inventory/import-button.tsx
Normal file
@@ -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<HomeBoxItem[] | null>(null);
|
||||
const [skip, setSkip] = useState<Set<string>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(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 (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
||||
<Upload className="h-4 w-4 mr-1" /> Import
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) { setRows(null); setCsv(""); } }}>
|
||||
<DialogContent className="max-w-xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader><DialogTitle>Import from HomeBox CSV</DialogTitle></DialogHeader>
|
||||
|
||||
{!rows ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Paste a HomeBox CSV export, or choose a file. You'll review every item before importing.
|
||||
</p>
|
||||
<Textarea rows={6} value={csv} onChange={(e) => setCsv(e.target.value)} placeholder="HB.location,HB.labels,HB.name,…" className="font-mono text-xs" />
|
||||
<input ref={inputRef} type="file" accept=".csv,text/csv" className="hidden"
|
||||
onChange={async (e) => { const f = e.target.files?.[0]; if (f) preview(await f.text()); e.target.value = ""; }} />
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => inputRef.current?.click()}><FileUp className="h-4 w-4 mr-1" />Choose file</Button>
|
||||
<Button size="sm" onClick={() => preview(csv)} disabled={!csv.trim()}>Preview</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{rows.length} items found. Uncheck any you don't want, then import.
|
||||
</p>
|
||||
<div className="divide-y border rounded-lg max-h-[50vh] overflow-y-auto">
|
||||
{rows.map((r, i) => {
|
||||
const on = !skip.has(r.name);
|
||||
return (
|
||||
<label key={`${r.name}-${i}`} className="flex items-start gap-2.5 px-3 py-2 cursor-pointer hover:bg-accent text-sm">
|
||||
<input type="checkbox" checked={on} onChange={() => toggle(r.name)} className="mt-1 accent-[hsl(var(--leaf))]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`font-medium ${on ? "" : "line-through text-muted-foreground"}`}>{r.name}</p>
|
||||
<div className="flex flex-wrap gap-1 mt-0.5 items-center text-xs text-muted-foreground">
|
||||
{r.locationPath && <span>{r.locationPath}</span>}
|
||||
{r.labels.map((l) => <Badge key={l} variant="secondary" className="text-[10px]">{l}</Badge>)}
|
||||
{r.value != null && <span>· ${r.value}</span>}
|
||||
{r.soldTo && <Badge variant="outline" className="text-[10px]">sold</Badge>}
|
||||
{Object.keys(r.customFields).length > 0 && <span>· {Object.keys(r.customFields).length} custom</span>}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setOpen(false); setRows(null); setCsv(""); }}>Cancel</Button>
|
||||
{rows && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setRows(null)}>Back</Button>
|
||||
<Button onClick={doImport} disabled={busy || includeCount === 0}>
|
||||
{busy ? "Importing…" : `Import ${includeCount}`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,11 @@ export type ItemFields = {
|
||||
warrantyDetails: string;
|
||||
lifetimeWarranty: boolean;
|
||||
insured: boolean;
|
||||
soldTo: string;
|
||||
soldPrice: string;
|
||||
soldDate: string;
|
||||
soldNotes: string;
|
||||
customFields: { key: string; value: string }[];
|
||||
barcode: string;
|
||||
notes: string;
|
||||
labelIds: string[];
|
||||
@@ -48,6 +53,7 @@ export function blankItem(): ItemFields {
|
||||
name: "", description: "", quantity: "1", unit: "", locationId: "", parentItemId: "",
|
||||
brand: "", modelNumber: "", serial: "", value: "", purchaseDate: "", purchaseFrom: "",
|
||||
warrantyExpiry: "", warrantyDetails: "", lifetimeWarranty: false, insured: false,
|
||||
soldTo: "", soldPrice: "", soldDate: "", soldNotes: "", customFields: [],
|
||||
barcode: "", notes: "", labelIds: [],
|
||||
};
|
||||
}
|
||||
@@ -119,6 +125,13 @@ function ItemDialog({
|
||||
warrantyDetails: f.warrantyDetails.trim() || undefined,
|
||||
lifetimeWarranty: f.lifetimeWarranty,
|
||||
insured: f.insured,
|
||||
soldTo: f.soldTo.trim() || undefined,
|
||||
soldPrice: f.soldPrice ? Number(f.soldPrice) : null,
|
||||
soldDate: f.soldDate || null,
|
||||
soldNotes: f.soldNotes.trim() || undefined,
|
||||
customFields: Object.fromEntries(
|
||||
f.customFields.map((c) => [c.key.trim(), c.value.trim()]).filter(([k, v]) => k && v),
|
||||
),
|
||||
barcode: f.barcode.trim() || undefined,
|
||||
notes: f.notes.trim() || undefined,
|
||||
labelIds: f.labelIds,
|
||||
@@ -247,6 +260,31 @@ function ItemDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label>Custom fields</Label>
|
||||
{f.customFields.map((cf, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<Input placeholder="Name (VIN…)" value={cf.key}
|
||||
onChange={(e) => setF((p) => ({ ...p, customFields: p.customFields.map((c, j) => j === i ? { ...c, key: e.target.value } : c) }))} />
|
||||
<Input placeholder="Value" value={cf.value}
|
||||
onChange={(e) => setF((p) => ({ ...p, customFields: p.customFields.map((c, j) => j === i ? { ...c, value: e.target.value } : c) }))} />
|
||||
<Button type="button" variant="outline" size="sm" className="px-2"
|
||||
onClick={() => setF((p) => ({ ...p, customFields: p.customFields.filter((_, j) => j !== i) }))}>×</Button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="text-sm text-[hsl(var(--leaf))] hover:underline"
|
||||
onClick={() => setF((p) => ({ ...p, customFields: [...p.customFields, { key: "", value: "" }] }))}>
|
||||
+ Add field
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5"><Label>Sold to</Label><Input value={f.soldTo} onChange={(e) => set("soldTo", e.target.value)} placeholder="(if sold)" /></div>
|
||||
<div className="space-y-1.5"><Label>Sold price ($)</Label><Input type="number" min="0" step="0.01" value={f.soldPrice} onChange={(e) => set("soldPrice", e.target.value)} /></div>
|
||||
<div className="space-y-1.5"><Label>Sold date</Label><Input type="date" value={f.soldDate} onChange={(e) => set("soldDate", e.target.value)} /></div>
|
||||
<div className="space-y-1.5"><Label>Sold notes</Label><Input value={f.soldNotes} onChange={(e) => set("soldNotes", e.target.value)} /></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)} />
|
||||
|
||||
@@ -44,6 +44,11 @@ export const itemCreateInput = z.object({
|
||||
serial: z.string().optional(),
|
||||
modelNumber: z.string().optional(),
|
||||
brand: z.string().optional(),
|
||||
soldTo: z.string().optional(),
|
||||
soldPrice: z.coerce.number().nonnegative().nullable().optional(),
|
||||
soldDate: z.string().nullable().optional(),
|
||||
soldNotes: z.string().optional(),
|
||||
customFields: z.record(z.string()).nullable().optional(),
|
||||
barcode: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
imageUrl: z.string().nullable().optional(),
|
||||
|
||||
@@ -8,6 +8,14 @@ export type ChangelogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.16.0",
|
||||
date: "2026-06-16",
|
||||
changes: [
|
||||
"Import your inventory from HomeBox — on the Inventory page, click Import, paste or upload your HomeBox CSV, review every item (uncheck any you don't want), and it brings them in, creating the locations and labels for you.",
|
||||
"Items can now record custom fields (like VIN or oil weight for vehicles) and a 'sold' record (who to, price, when) — and you can edit both on any item.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.15.0",
|
||||
date: "2026-06-16",
|
||||
|
||||
61
src/lib/inventory/homebox-import.test.ts
Normal file
61
src/lib/inventory/homebox-import.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseCsv, parseHomeBoxCsv, splitLocationPath } from "./homebox-import";
|
||||
|
||||
describe("parseCsv", () => {
|
||||
it("handles quoted fields with embedded commas and quotes", () => {
|
||||
const rows = parseCsv('a,"b,c","d""e"\n1,2,3');
|
||||
expect(rows[0]).toEqual(["a", "b,c", 'd"e']);
|
||||
expect(rows[1]).toEqual(["1", "2", "3"]);
|
||||
});
|
||||
|
||||
it("handles newlines inside quoted fields", () => {
|
||||
const rows = parseCsv('name,desc\n"Mower","200cc\nrear bag"');
|
||||
expect(rows[1]).toEqual(["Mower", "200cc\nrear bag"]);
|
||||
});
|
||||
});
|
||||
|
||||
const HEADER =
|
||||
"HB.location,HB.labels,HB.name,HB.quantity,HB.description,HB.insured,HB.purchase_price,HB.purchase_from,HB.purchase_time,HB.manufacturer,HB.lifetime_warranty,HB.warranty_expires,HB.sold_to,HB.sold_price,HB.sold_time,HB.field.VIN,HB.field.Oil Weight";
|
||||
|
||||
describe("parseHomeBoxCsv", () => {
|
||||
it("maps columns and applies HomeBox quirks", () => {
|
||||
const csv = [
|
||||
HEADER,
|
||||
'Living Room,Electronics,"Samsung 70""",1,,false,0,,0001-01-01,,false,0001-01-01,,0,0001-01-01,,',
|
||||
'Vehicles,,2012 Equinox,1,,true,400,Dealer,2024-05-01,Chevy,false,0001-01-01,Alter,525,2024-05-24,1ABC,5W-30',
|
||||
].join("\n");
|
||||
const items = parseHomeBoxCsv(csv);
|
||||
expect(items).toHaveLength(2);
|
||||
|
||||
const tv = items[0];
|
||||
expect(tv.name).toBe('Samsung 70"');
|
||||
expect(tv.labels).toEqual(["Electronics"]);
|
||||
expect(tv.value).toBeNull(); // price 0 → null
|
||||
expect(tv.purchaseDate).toBeNull(); // 0001 → null
|
||||
expect(tv.insured).toBe(false);
|
||||
|
||||
const car = items[1];
|
||||
expect(car.value).toBe(400);
|
||||
expect(car.purchaseDate).toBe("2024-05-01");
|
||||
expect(car.insured).toBe(true);
|
||||
expect(car.soldTo).toBe("Alter");
|
||||
expect(car.soldPrice).toBe(525);
|
||||
expect(car.soldDate).toBe("2024-05-24");
|
||||
expect(car.customFields).toEqual({ VIN: "1ABC", "Oil Weight": "5W-30" });
|
||||
});
|
||||
|
||||
it("splits semicolon labels and skips nameless rows", () => {
|
||||
const csv = [HEADER, "Basement,Tools; Woodworking,Planer,1,,false,0,,0001,,false,0001,,0,0001,,",
|
||||
"Basement,,,1,,false,0,,0001,,false,0001,,0,0001,,"].join("\n");
|
||||
const items = parseHomeBoxCsv(csv);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].labels).toEqual(["Tools", "Woodworking"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitLocationPath", () => {
|
||||
it("splits nested HomeBox paths", () => {
|
||||
expect(splitLocationPath("Upstairs / Print Room")).toEqual(["Upstairs", "Print Room"]);
|
||||
expect(splitLocationPath("Vehicles")).toEqual(["Vehicles"]);
|
||||
});
|
||||
});
|
||||
131
src/lib/inventory/homebox-import.ts
Normal file
131
src/lib/inventory/homebox-import.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// Pure HomeBox CSV import parsing. No DB — turns a HomeBox export into normalized
|
||||
// item rows the import service can create. Handles RFC4180 quoting (commas and
|
||||
// newlines inside quoted fields, "" escapes), HomeBox's 0001-* null dates, its
|
||||
// "0" = empty price, semicolon-separated labels, and HB.field.* custom columns.
|
||||
|
||||
export type HomeBoxItem = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
quantity: number;
|
||||
locationPath: string | null; // e.g. "Upstairs / Print Room"
|
||||
labels: string[];
|
||||
insured: boolean;
|
||||
notes: string | null;
|
||||
value: number | null;
|
||||
purchaseFrom: string | null;
|
||||
purchaseDate: string | null;
|
||||
brand: string | null;
|
||||
modelNumber: string | null;
|
||||
serial: string | null;
|
||||
lifetimeWarranty: boolean;
|
||||
warrantyExpiry: string | null;
|
||||
warrantyDetails: string | null;
|
||||
soldTo: string | null;
|
||||
soldPrice: number | null;
|
||||
soldDate: string | null;
|
||||
soldNotes: string | null;
|
||||
customFields: Record<string, string>;
|
||||
archived: boolean;
|
||||
};
|
||||
|
||||
/** RFC4180-ish CSV tokenizer → array of rows of string cells. */
|
||||
export function parseCsv(text: string): string[][] {
|
||||
const s = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = "";
|
||||
let inQuotes = false;
|
||||
let i = 0;
|
||||
while (i < s.length) {
|
||||
const c = s[i];
|
||||
if (inQuotes) {
|
||||
if (c === '"') {
|
||||
if (s[i + 1] === '"') { field += '"'; i += 2; continue; }
|
||||
inQuotes = false; i++; continue;
|
||||
}
|
||||
field += c; i++; continue;
|
||||
}
|
||||
if (c === '"') { inQuotes = true; i++; continue; }
|
||||
if (c === ",") { row.push(field); field = ""; i++; continue; }
|
||||
if (c === "\n") { row.push(field); rows.push(row); row = []; field = ""; i++; continue; }
|
||||
field += c; i++;
|
||||
}
|
||||
if (field !== "" || row.length > 0) { row.push(field); rows.push(row); }
|
||||
return rows;
|
||||
}
|
||||
|
||||
function cleanDate(v: string): string | null {
|
||||
const t = v.trim();
|
||||
// HomeBox exports "no date" as year 0001.
|
||||
if (!t || t.startsWith("0001")) return null;
|
||||
return Number.isNaN(new Date(t).getTime()) ? null : t;
|
||||
}
|
||||
|
||||
function cleanNum(v: string): number | null {
|
||||
const t = v.trim();
|
||||
if (!t) return null;
|
||||
const n = Number(t);
|
||||
// HomeBox exports empty price as 0.
|
||||
return Number.isNaN(n) || n === 0 ? null : n;
|
||||
}
|
||||
|
||||
const isTrue = (v: string) => v.trim().toLowerCase() === "true";
|
||||
|
||||
export function parseHomeBoxCsv(text: string): HomeBoxItem[] {
|
||||
const rows = parseCsv(text).filter((r) => r.some((c) => c.trim() !== ""));
|
||||
if (rows.length < 2) return [];
|
||||
|
||||
const header = rows[0].map((h) => h.trim());
|
||||
const idx = (name: string) => header.indexOf(name);
|
||||
const fieldCols = header
|
||||
.map((h, i) => ({ key: h.replace(/^HB\.field\./, ""), i, isField: h.startsWith("HB.field.") }))
|
||||
.filter((x) => x.isField);
|
||||
|
||||
const items: HomeBoxItem[] = [];
|
||||
for (let r = 1; r < rows.length; r++) {
|
||||
const row = rows[r];
|
||||
const get = (name: string) => {
|
||||
const i = idx(name);
|
||||
return i >= 0 ? (row[i] ?? "").trim() : "";
|
||||
};
|
||||
const name = get("HB.name");
|
||||
if (!name) continue;
|
||||
|
||||
const customFields: Record<string, string> = {};
|
||||
for (const fc of fieldCols) {
|
||||
const val = (row[fc.i] ?? "").trim();
|
||||
if (val) customFields[fc.key] = val;
|
||||
}
|
||||
|
||||
items.push({
|
||||
name,
|
||||
description: get("HB.description") || null,
|
||||
quantity: Number(get("HB.quantity")) || 1,
|
||||
locationPath: get("HB.location") || null,
|
||||
labels: get("HB.labels").split(";").map((s) => s.trim()).filter(Boolean),
|
||||
insured: isTrue(get("HB.insured")),
|
||||
notes: get("HB.notes") || null,
|
||||
value: cleanNum(get("HB.purchase_price")),
|
||||
purchaseFrom: get("HB.purchase_from") || null,
|
||||
purchaseDate: cleanDate(get("HB.purchase_time")),
|
||||
brand: get("HB.manufacturer") || null,
|
||||
modelNumber: get("HB.model_number") || null,
|
||||
serial: get("HB.serial_number") || null,
|
||||
lifetimeWarranty: isTrue(get("HB.lifetime_warranty")),
|
||||
warrantyExpiry: cleanDate(get("HB.warranty_expires")),
|
||||
warrantyDetails: get("HB.warranty_details") || null,
|
||||
soldTo: get("HB.sold_to") || null,
|
||||
soldPrice: cleanNum(get("HB.sold_price")),
|
||||
soldDate: cleanDate(get("HB.sold_time")),
|
||||
soldNotes: get("HB.sold_notes") || null,
|
||||
customFields,
|
||||
archived: isTrue(get("HB.archived")),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Split a HomeBox location path ("Upstairs / Print Room") into nested names. */
|
||||
export function splitLocationPath(path: string): string[] {
|
||||
return path.split("/").map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
141
src/lib/services/inventory-import.ts
Normal file
141
src/lib/services/inventory-import.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
// Import service for HomeBox CSV → Moonbase items. Resolves (find-or-creates)
|
||||
// the nested location path and labels, then creates the items. The parsing is
|
||||
// pure (src/lib/inventory/homebox-import.ts) and shared with the preview UI.
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { db } from "@/lib/db";
|
||||
import type { AuthContext } from "@/lib/api/authenticate";
|
||||
import { recomputeAllPaths } from "@/lib/locations/db";
|
||||
import { parseHomeBoxCsv, splitLocationPath, type HomeBoxItem } from "@/lib/inventory/homebox-import";
|
||||
|
||||
export type ImportSummary = {
|
||||
created: number;
|
||||
skipped: number;
|
||||
locationsCreated: number;
|
||||
labelsCreated: number;
|
||||
};
|
||||
|
||||
async function buildLocationCache() {
|
||||
const rows = await db.location.findMany({ select: { id: true, name: true, parentId: true } });
|
||||
const cache = new Map<string, string>(); // `${parentId||''}:${nameLower}` → id
|
||||
for (const r of rows) cache.set(`${r.parentId ?? ""}:${r.name.toLowerCase()}`, r.id);
|
||||
return cache;
|
||||
}
|
||||
|
||||
async function resolveLocationPath(
|
||||
path: string | null,
|
||||
cache: Map<string, string>,
|
||||
counter: { n: number },
|
||||
): Promise<string | null> {
|
||||
if (!path) return null;
|
||||
const names = splitLocationPath(path);
|
||||
let parentId: string | null = null;
|
||||
for (const name of names) {
|
||||
const key = `${parentId ?? ""}:${name.toLowerCase()}`;
|
||||
const existing: string | undefined = cache.get(key);
|
||||
let id: string;
|
||||
if (existing) {
|
||||
id = existing;
|
||||
} else {
|
||||
const loc = await db.location.create({ data: { name, kind: "OTHER", parentId } });
|
||||
id = loc.id;
|
||||
cache.set(key, id);
|
||||
counter.n++;
|
||||
}
|
||||
parentId = id;
|
||||
}
|
||||
return parentId;
|
||||
}
|
||||
|
||||
async function buildLabelCache() {
|
||||
const rows = await db.label.findMany({ select: { id: true, name: true } });
|
||||
const cache = new Map<string, string>();
|
||||
for (const r of rows) cache.set(r.name.toLowerCase(), r.id);
|
||||
return cache;
|
||||
}
|
||||
|
||||
async function resolveLabels(
|
||||
names: string[],
|
||||
cache: Map<string, string>,
|
||||
counter: { n: number },
|
||||
): Promise<string[]> {
|
||||
const ids: string[] = [];
|
||||
for (const name of names) {
|
||||
let id = cache.get(name.toLowerCase());
|
||||
if (!id) {
|
||||
const label = await db.label.create({ data: { name } });
|
||||
id = label.id;
|
||||
cache.set(name.toLowerCase(), id);
|
||||
counter.n++;
|
||||
}
|
||||
ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function toDate(s: string | null): Date | null {
|
||||
return s ? new Date(s) : null;
|
||||
}
|
||||
|
||||
export async function importHomeBoxItems(
|
||||
ctx: AuthContext,
|
||||
opts: { csv: string; skip?: string[] },
|
||||
): Promise<ImportSummary> {
|
||||
const parsed = parseHomeBoxCsv(opts.csv);
|
||||
const skip = new Set((opts.skip ?? []).map((s) => s.trim().toLowerCase()));
|
||||
|
||||
const locCache = await buildLocationCache();
|
||||
const labelCache = await buildLabelCache();
|
||||
const locCounter = { n: 0 };
|
||||
const labelCounter = { n: 0 };
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const hb of parsed as HomeBoxItem[]) {
|
||||
if (skip.has(hb.name.trim().toLowerCase())) { skipped++; continue; }
|
||||
|
||||
const locationId = await resolveLocationPath(hb.locationPath, locCache, locCounter);
|
||||
const labelIds = await resolveLabels(hb.labels, labelCache, labelCounter);
|
||||
|
||||
await db.item.create({
|
||||
data: {
|
||||
name: hb.name,
|
||||
description: hb.description,
|
||||
quantity: hb.quantity || 1,
|
||||
locationId,
|
||||
value: hb.value,
|
||||
purchaseDate: toDate(hb.purchaseDate),
|
||||
purchaseFrom: hb.purchaseFrom,
|
||||
warrantyExpiry: toDate(hb.warrantyExpiry),
|
||||
warrantyDetails: hb.warrantyDetails,
|
||||
lifetimeWarranty: hb.lifetimeWarranty,
|
||||
insured: hb.insured,
|
||||
serial: hb.serial,
|
||||
modelNumber: hb.modelNumber,
|
||||
brand: hb.brand,
|
||||
notes: hb.notes,
|
||||
soldTo: hb.soldTo,
|
||||
soldPrice: hb.soldPrice,
|
||||
soldDate: toDate(hb.soldDate),
|
||||
soldNotes: hb.soldNotes,
|
||||
customFields: Object.keys(hb.customFields).length ? hb.customFields : undefined,
|
||||
active: !hb.archived,
|
||||
qrSlug: createId().slice(0, 10),
|
||||
...(labelIds.length ? { labels: { create: labelIds.map((labelId) => ({ labelId })) } } : {}),
|
||||
},
|
||||
});
|
||||
created++;
|
||||
}
|
||||
|
||||
// New locations were created without paths — recompute breadcrumbs once.
|
||||
if (locCounter.n > 0) await recomputeAllPaths();
|
||||
|
||||
await db.auditEvent.create({
|
||||
data: {
|
||||
action: "item.import",
|
||||
actorId: ctx.userId,
|
||||
payload: { created, skipped, source: "homebox", via: ctx.via },
|
||||
},
|
||||
});
|
||||
|
||||
return { created, skipped, locationsCreated: locCounter.n, labelsCreated: labelCounter.n };
|
||||
}
|
||||
@@ -90,6 +90,11 @@ export async function createItem(ctx: AuthContext, input: ItemCreateInput) {
|
||||
serial: r.serial?.trim() || null,
|
||||
modelNumber: r.modelNumber?.trim() || null,
|
||||
brand: r.brand?.trim() || null,
|
||||
soldTo: r.soldTo?.trim() || null,
|
||||
soldPrice: r.soldPrice ?? null,
|
||||
soldDate: toDate(r.soldDate),
|
||||
soldNotes: r.soldNotes?.trim() || null,
|
||||
customFields: r.customFields && Object.keys(r.customFields).length ? r.customFields : undefined,
|
||||
barcode: r.barcode?.trim() || null,
|
||||
notes: r.notes?.trim() || null,
|
||||
imageUrl: r.imageUrl || null,
|
||||
@@ -133,6 +138,12 @@ export async function updateItem(ctx: AuthContext, id: string, input: ItemUpdate
|
||||
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.soldTo !== undefined) data.soldTo = r.soldTo?.trim() || null;
|
||||
if (r.soldPrice !== undefined) data.soldPrice = r.soldPrice;
|
||||
if (r.soldDate !== undefined) data.soldDate = toDate(r.soldDate);
|
||||
if (r.soldNotes !== undefined) data.soldNotes = r.soldNotes?.trim() || null;
|
||||
if (r.customFields !== undefined)
|
||||
data.customFields = r.customFields && Object.keys(r.customFields).length ? r.customFields : Prisma.DbNull;
|
||||
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;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
|
||||
export const APP_VERSION = "0.15.0";
|
||||
export const APP_VERSION = "0.16.0";
|
||||
|
||||
Reference in New Issue
Block a user