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:
2026-06-16 01:01:17 -05:00
parent 63569aabd3
commit 5f4c482636
14 changed files with 604 additions and 2 deletions

View File

@@ -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>

View File

@@ -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} />

View 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);
}
}

View 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&apos;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&apos;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>
</>
);
}

View File

@@ -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)} />

View File

@@ -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(),

View File

@@ -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",

View 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"]);
});
});

View 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);
}

View 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 };
}

View File

@@ -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;

View File

@@ -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";