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>
135 lines
6.0 KiB
TypeScript
135 lines
6.0 KiB
TypeScript
"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>
|
|
</>
|
|
);
|
|
}
|