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

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