Compare commits

...

2 Commits

Author SHA1 Message Date
5f4c482636 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>
2026-06-16 01:01:17 -05:00
63569aabd3 v0.15.0 — Inventory depth (HomeBox parity) + Locations page
Rounds out Inventory toward HomeBox, and gives Locations a real home.

Inventory:
- Attach manuals, receipts, and warranty docs (any file), not just photos —
  attachment uploader now has a kind picker; WARRANTY kind added
- Richer purchase/warranty: purchasedFrom, warrantyDetails, lifetimeWarranty,
  insured (item dialog + detail facts + header badge)
- Schedule recurring maintenance from an item (creates a Task linked via
  itemId); item detail shows its maintenance with overdue flagged. Task create
  now accepts itemId/animalId

Locations:
- New Locations page + nav: browse/add/rename/move/delete the whole place tree
  (LocationManager over the existing /api/locations routes); shows plant/item
  counts per place. The spot to merge near-dupes before dropping legacy zone

Verified end-to-end against a running server (new fields round-trip, maintenance
task links to item, MANUAL attachment stored). tsc + 33 tests + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:34:07 -05:00
22 changed files with 1084 additions and 22 deletions

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Item" ADD COLUMN "insured" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "lifetimeWarranty" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "purchaseFrom" TEXT,
ADD COLUMN "warrantyDetails" TEXT;

View File

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

View File

@@ -314,12 +314,25 @@ model Item {
containedItems Item[] @relation("ItemNesting") containedItems Item[] @relation("ItemNesting")
// Durable attributes // Durable attributes
value Decimal? @db.Decimal(10, 2) value Decimal? @db.Decimal(10, 2) // purchase price
purchaseDate DateTime? purchaseDate DateTime?
purchaseFrom String? // where it was bought
warrantyExpiry DateTime? warrantyExpiry DateTime?
warrantyDetails String? // freeform warranty terms
lifetimeWarranty Boolean @default(false)
insured Boolean @default(false)
serial String? serial String?
modelNumber String? modelNumber String?
brand 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) // Consumable attributes (Pantry phase)
barcode String? barcode String?

View File

@@ -5,13 +5,16 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { import {
ChevronLeft, MapPin, Box, Package, CalendarDays, DollarSign, Hash, Tag, FileText, Barcode, ChevronLeft, MapPin, Box, Package, CalendarDays, DollarSign, Hash, Tag, FileText, Barcode,
Store, ShieldCheck, Wrench, AlertCircle,
} from "lucide-react"; } from "lucide-react";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { warrantyStatus, WARRANTY_LABELS } from "@/lib/inventory/value"; import { warrantyStatus, WARRANTY_LABELS } from "@/lib/inventory/value";
import { ITEM_LOG_LABELS, ITEM_LOG_COLORS } from "@/lib/inventory/item-fields"; import { ITEM_LOG_LABELS, ITEM_LOG_COLORS } from "@/lib/inventory/item-fields";
import { isOverdue, describeRecurrence } from "@/lib/tasks/schedule";
import { EditItemButton, type ItemFields } from "@/components/inventory/item-dialog"; import { EditItemButton, type ItemFields } from "@/components/inventory/item-dialog";
import { DeleteItemButton } from "@/components/inventory/delete-item-button"; import { DeleteItemButton } from "@/components/inventory/delete-item-button";
import { AddItemLogButton } from "@/components/inventory/add-item-log-button"; import { AddItemLogButton } from "@/components/inventory/add-item-log-button";
import { ScheduleMaintenanceButton } from "@/components/inventory/schedule-maintenance-button";
import { AttachmentUpload } from "@/components/inventory/attachment-upload"; import { AttachmentUpload } from "@/components/inventory/attachment-upload";
function toDateInput(d: Date | null): string { function toDateInput(d: Date | null): string {
@@ -34,6 +37,7 @@ export default async function ItemDetailPage({ params }: { params: { id: string
attachments: { orderBy: { createdAt: "asc" } }, attachments: { orderBy: { createdAt: "asc" } },
logs: { orderBy: { date: "desc" } }, logs: { orderBy: { date: "desc" } },
containedItems: { where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }, containedItems: { where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } },
tasks: { where: { active: true }, orderBy: { nextDue: "asc" } },
}, },
}), }),
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }), db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
@@ -43,7 +47,8 @@ export default async function ItemDetailPage({ params }: { params: { id: string
if (!item || !item.active) notFound(); if (!item || !item.active) notFound();
const wStatus = warrantyStatus(item.warrantyExpiry, new Date()); const wStatus = item.lifetimeWarranty ? "active" : warrantyStatus(item.warrantyExpiry, new Date());
const warrantyLabel = item.lifetimeWarranty ? "Lifetime warranty" : WARRANTY_LABELS[wStatus];
const photos = item.attachments.filter((a) => a.kind === "PHOTO"); const photos = item.attachments.filter((a) => a.kind === "PHOTO");
const docs = item.attachments.filter((a) => a.kind !== "PHOTO"); const docs = item.attachments.filter((a) => a.kind !== "PHOTO");
@@ -60,7 +65,16 @@ export default async function ItemDetailPage({ params }: { params: { id: string
serial: item.serial ?? "", serial: item.serial ?? "",
value: item.value != null ? String(Number(item.value)) : "", value: item.value != null ? String(Number(item.value)) : "",
purchaseDate: toDateInput(item.purchaseDate), purchaseDate: toDateInput(item.purchaseDate),
purchaseFrom: item.purchaseFrom ?? "",
warrantyExpiry: toDateInput(item.warrantyExpiry), warrantyExpiry: toDateInput(item.warrantyExpiry),
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 ?? "", barcode: item.barcode ?? "",
notes: item.notes ?? "", notes: item.notes ?? "",
labelIds: item.labels.map((l) => l.labelId), labelIds: item.labels.map((l) => l.labelId),
@@ -72,6 +86,9 @@ export default async function ItemDetailPage({ params }: { params: { id: string
if (Number(item.quantity) !== 1) facts.push({ icon: <Package className="h-4 w-4" />, label: "Quantity", value: `${Number(item.quantity)}${item.unit ? ` ${item.unit}` : ""}` }); if (Number(item.quantity) !== 1) facts.push({ icon: <Package className="h-4 w-4" />, label: "Quantity", value: `${Number(item.quantity)}${item.unit ? ` ${item.unit}` : ""}` });
if (item.value != null) facts.push({ icon: <DollarSign className="h-4 w-4" />, label: "Value", value: Number(item.value).toLocaleString(undefined, { style: "currency", currency: "USD" }) }); if (item.value != null) facts.push({ icon: <DollarSign className="h-4 w-4" />, label: "Value", value: Number(item.value).toLocaleString(undefined, { style: "currency", currency: "USD" }) });
if (item.purchaseDate) facts.push({ icon: <CalendarDays className="h-4 w-4" />, label: "Purchased", value: formatDate(item.purchaseDate) }); if (item.purchaseDate) facts.push({ icon: <CalendarDays className="h-4 w-4" />, label: "Purchased", value: formatDate(item.purchaseDate) });
if (item.purchaseFrom) facts.push({ icon: <Store className="h-4 w-4" />, label: "From", value: item.purchaseFrom });
if (item.lifetimeWarranty || item.warrantyExpiry) facts.push({ icon: <ShieldCheck className="h-4 w-4" />, label: "Warranty", value: item.lifetimeWarranty ? "Lifetime" : `until ${formatDate(item.warrantyExpiry!)}` });
if (item.insured) facts.push({ icon: <ShieldCheck className="h-4 w-4" />, label: "Insured", value: "Yes" });
if (item.brand) facts.push({ icon: <Tag className="h-4 w-4" />, label: "Brand", value: item.brand }); if (item.brand) facts.push({ icon: <Tag className="h-4 w-4" />, label: "Brand", value: item.brand });
if (item.modelNumber) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Model", value: item.modelNumber }); if (item.modelNumber) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Model", value: item.modelNumber });
if (item.serial) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Serial", value: item.serial }); if (item.serial) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Serial", value: item.serial });
@@ -87,14 +104,15 @@ export default async function ItemDetailPage({ params }: { params: { id: string
<h1 className="font-display text-2xl font-semibold">{item.name}</h1> <h1 className="font-display text-2xl font-semibold">{item.name}</h1>
{item.description && <p className="text-sm text-muted-foreground">{item.description}</p>} {item.description && <p className="text-sm text-muted-foreground">{item.description}</p>}
</div> </div>
{wStatus !== "none" && ( {(item.lifetimeWarranty || wStatus !== "none") && (
<Badge variant="outline" className="ml-auto shrink-0">{WARRANTY_LABELS[wStatus]}</Badge> <Badge variant="outline" className="ml-auto shrink-0">{warrantyLabel}</Badge>
)} )}
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<EditItemButton item={editFields} locations={locations} items={allItems.filter((i) => i.id !== item.id)} labels={labels} /> <EditItemButton item={editFields} locations={locations} items={allItems.filter((i) => i.id !== item.id)} labels={labels} />
<AddItemLogButton itemId={item.id} /> <AddItemLogButton itemId={item.id} />
<ScheduleMaintenanceButton itemId={item.id} itemName={item.name} />
<AttachmentUpload itemId={item.id} /> <AttachmentUpload itemId={item.id} />
<DeleteItemButton id={item.id} name={item.name} /> <DeleteItemButton id={item.id} name={item.name} />
</div> </div>
@@ -134,6 +152,71 @@ export default async function ItemDetailPage({ params }: { params: { id: string
<Card><CardContent className="pt-4 text-sm whitespace-pre-wrap">{item.notes}</CardContent></Card> <Card><CardContent className="pt-4 text-sm whitespace-pre-wrap">{item.notes}</CardContent></Card>
)} )}
{item.warrantyDetails && (
<Card>
<CardContent className="pt-4 text-sm">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Warranty</p>
<p className="whitespace-pre-wrap">{item.warrantyDetails}</p>
</CardContent>
</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>
</div>
{item.tasks.length === 0 ? (
<p className="text-sm text-muted-foreground">
No maintenance scheduled. Use &ldquo;Schedule maintenance&rdquo; above to set a recurring reminder.
</p>
) : (
<div className="divide-y border rounded-lg overflow-hidden">
{item.tasks.map((t) => {
const overdue = isOverdue(t.nextDue);
return (
<div key={t.id} className="flex items-center gap-2 px-3 py-2.5 text-sm">
<Wrench className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="flex-1 min-w-0 truncate">{t.title}
<span className="text-xs text-muted-foreground"> · {describeRecurrence(t.recurrence, t.intervalDays, t.month)}</span>
</span>
<span className={`flex items-center gap-1 text-xs shrink-0 ${overdue ? "text-[hsl(var(--ember))]" : "text-muted-foreground"}`}>
{overdue && <AlertCircle className="h-3 w-3" />}
{overdue ? "Overdue " : "Due "}{formatDate(t.nextDue)}
</span>
</div>
);
})}
</div>
)}
</div>
{docs.length > 0 && ( {docs.length > 0 && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<h2 className="font-display text-sm font-semibold">Documents</h2> <h2 className="font-display text-sm font-semibold">Documents</h2>

View File

@@ -1,5 +1,6 @@
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { AddItemButton } from "@/components/inventory/item-dialog"; import { AddItemButton } from "@/components/inventory/item-dialog";
import { ImportButton } from "@/components/inventory/import-button";
import { InventoryGrid, type GridItem } from "@/components/inventory/inventory-grid"; import { InventoryGrid, type GridItem } from "@/components/inventory/inventory-grid";
import { totalValue } from "@/lib/inventory/value"; import { totalValue } from "@/lib/inventory/value";
@@ -53,8 +54,11 @@ export default async function InventoryPage() {
{total > 0 && ` · ${total.toLocaleString(undefined, { style: "currency", currency: "USD" })} total value`} {total > 0 && ` · ${total.toLocaleString(undefined, { style: "currency", currency: "USD" })} total value`}
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
<ImportButton />
<AddItemButton locations={locations} items={itemOpts} labels={labels} /> <AddItemButton locations={locations} items={itemOpts} labels={labels} />
</div> </div>
</div>
<InventoryGrid items={gridItems} /> <InventoryGrid items={gridItems} />
</div> </div>

View File

@@ -0,0 +1,35 @@
import { db } from "@/lib/db";
import { LocationManager } from "@/components/locations/location-manager";
export const metadata = { title: "Locations" };
export default async function LocationsPage() {
const locations = await db.location.findMany({
where: { active: true },
orderBy: { name: "asc" },
select: {
id: true,
name: true,
kind: true,
parentId: true,
_count: {
select: {
plants: { where: { active: true } },
items: { where: { active: true } },
},
},
},
});
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="font-display text-2xl font-semibold">Locations</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Manage the places on your property nest them as deep as you like.
</p>
</div>
<LocationManager locations={locations} />
</div>
);
}

View File

@@ -6,7 +6,7 @@ import { authenticateRequest, handleApiError, ApiError } from "@/lib/api/authent
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const KINDS = new Set(["PHOTO", "MANUAL", "RECEIPT"]); const KINDS = new Set(["PHOTO", "MANUAL", "RECEIPT", "WARRANTY"]);
// POST /api/v1/items/:id/attachments — multipart upload (file, kind). Files land // POST /api/v1/items/:id/attachments — multipart upload (file, kind). Files land
// under public/uploads/items (a persistent Docker volume), same as suggestions. // under public/uploads/items (a persistent Docker volume), same as suggestions.

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

@@ -3,33 +3,61 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { ImagePlus } from "lucide-react"; import { Paperclip, ChevronDown } from "lucide-react";
const KINDS: { value: string; label: string; accept: string }[] = [
{ value: "PHOTO", label: "Photo", accept: "image/*" },
{ value: "MANUAL", label: "Manual", accept: ".pdf,image/*" },
{ value: "RECEIPT", label: "Receipt", accept: ".pdf,image/*" },
{ value: "WARRANTY", label: "Warranty doc", accept: ".pdf,image/*" },
];
export function AttachmentUpload({ itemId }: { itemId: string }) { export function AttachmentUpload({ itemId }: { itemId: string }) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [kind, setKind] = useState("PHOTO");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
function pick(k: string) {
setKind(k);
// Let state settle, then open the picker with the right accept filter.
requestAnimationFrame(() => inputRef.current?.click());
}
async function onFile(file: File) { async function onFile(file: File) {
setBusy(true); setBusy(true);
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);
form.append("kind", "PHOTO"); form.append("kind", kind);
const res = await fetch(`/api/v1/items/${itemId}/attachments`, { method: "POST", body: form }); const res = await fetch(`/api/v1/items/${itemId}/attachments`, { method: "POST", body: form });
setBusy(false); setBusy(false);
if (res.ok) { toast({ title: "Photo added" }); router.refresh(); } if (res.ok) { toast({ title: "Attached" }); router.refresh(); }
else toast({ title: "Error", description: "Upload failed.", variant: "destructive" }); else toast({ title: "Error", description: "Upload failed.", variant: "destructive" });
} }
const accept = KINDS.find((k) => k.value === kind)?.accept ?? "*";
return ( return (
<> <>
<input ref={inputRef} type="file" accept="image/*" className="hidden" <input ref={inputRef} type="file" accept={accept} className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) onFile(f); e.target.value = ""; }} /> onChange={(e) => { const f = e.target.files?.[0]; if (f) onFile(f); e.target.value = ""; }} />
<Button size="sm" variant="outline" disabled={busy} onClick={() => inputRef.current?.click()}> <DropdownMenu>
<ImagePlus className="h-4 w-4 mr-1" /> {busy ? "Uploading…" : "Add photo"} <DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" disabled={busy}>
<Paperclip className="h-4 w-4 mr-1" /> {busy ? "Uploading…" : "Attach"} <ChevronDown className="h-3 w-3 ml-1" />
</Button> </Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{KINDS.map((k) => (
<DropdownMenuItem key={k.value} onClick={() => pick(k.value)}>{k.label}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</> </>
); );
} }

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

@@ -33,7 +33,16 @@ export type ItemFields = {
serial: string; serial: string;
value: string; value: string;
purchaseDate: string; purchaseDate: string;
purchaseFrom: string;
warrantyExpiry: string; warrantyExpiry: string;
warrantyDetails: string;
lifetimeWarranty: boolean;
insured: boolean;
soldTo: string;
soldPrice: string;
soldDate: string;
soldNotes: string;
customFields: { key: string; value: string }[];
barcode: string; barcode: string;
notes: string; notes: string;
labelIds: string[]; labelIds: string[];
@@ -42,7 +51,9 @@ export type ItemFields = {
export function blankItem(): ItemFields { export function blankItem(): ItemFields {
return { return {
name: "", description: "", quantity: "1", unit: "", locationId: "", parentItemId: "", name: "", description: "", quantity: "1", unit: "", locationId: "", parentItemId: "",
brand: "", modelNumber: "", serial: "", value: "", purchaseDate: "", warrantyExpiry: "", brand: "", modelNumber: "", serial: "", value: "", purchaseDate: "", purchaseFrom: "",
warrantyExpiry: "", warrantyDetails: "", lifetimeWarranty: false, insured: false,
soldTo: "", soldPrice: "", soldDate: "", soldNotes: "", customFields: [],
barcode: "", notes: "", labelIds: [], barcode: "", notes: "", labelIds: [],
}; };
} }
@@ -109,7 +120,18 @@ function ItemDialog({
serial: f.serial.trim() || undefined, serial: f.serial.trim() || undefined,
value: f.value ? Number(f.value) : null, value: f.value ? Number(f.value) : null,
purchaseDate: f.purchaseDate || null, purchaseDate: f.purchaseDate || null,
purchaseFrom: f.purchaseFrom.trim() || undefined,
warrantyExpiry: f.warrantyExpiry || null, warrantyExpiry: f.warrantyExpiry || null,
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, barcode: f.barcode.trim() || undefined,
notes: f.notes.trim() || undefined, notes: f.notes.trim() || undefined,
labelIds: f.labelIds, labelIds: f.labelIds,
@@ -197,7 +219,26 @@ function ItemDialog({
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Warranty until</Label> <Label>Warranty until</Label>
<Input type="date" value={f.warrantyExpiry} onChange={(e) => set("warrantyExpiry", e.target.value)} /> <Input type="date" value={f.warrantyExpiry} onChange={(e) => set("warrantyExpiry", e.target.value)} disabled={f.lifetimeWarranty} />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Purchased from</Label>
<Input value={f.purchaseFrom} onChange={(e) => set("purchaseFrom", e.target.value)} placeholder="Home Depot, Amazon, gift…" />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Warranty details</Label>
<Textarea rows={2} value={f.warrantyDetails} onChange={(e) => set("warrantyDetails", e.target.value)} placeholder="Coverage, claim info…" />
</div>
<div className="col-span-2 flex flex-wrap gap-x-6 gap-y-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={f.lifetimeWarranty} onChange={(e) => set("lifetimeWarranty", e.target.checked)} className="accent-[hsl(var(--leaf))]" />
Lifetime warranty
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={f.insured} onChange={(e) => set("insured", e.target.checked)} className="accent-[hsl(var(--leaf))]" />
Insured
</label>
</div> </div>
<div className="col-span-2 space-y-1.5"> <div className="col-span-2 space-y-1.5">
@@ -219,6 +260,31 @@ function ItemDialog({
</div> </div>
</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"> <div className="col-span-2 space-y-1.5">
<Label>Notes</Label> <Label>Notes</Label>
<Textarea rows={2} value={f.notes} onChange={(e) => set("notes", e.target.value)} /> <Textarea rows={2} value={f.notes} onChange={(e) => set("notes", e.target.value)} />

View File

@@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { Wrench } from "lucide-react";
import { MONTH_NAMES } from "@/lib/tasks/schedule";
export function ScheduleMaintenanceButton({ itemId, itemName }: { itemId: string; itemName: string }) {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState(`Maintain ${itemName}`);
const [recurrence, setRecurrence] = useState("YEARLY");
const [intervalDays, setIntervalDays] = useState("90");
const [month, setMonth] = useState("1");
const [nextDue, setNextDue] = useState(new Date().toISOString().slice(0, 10));
const [busy, setBusy] = useState(false);
const router = useRouter();
const { toast } = useToast();
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) return;
const body: Record<string, unknown> = {
title: title.trim(),
category: "EQUIPMENT",
itemId,
recurrence,
nextDue: new Date(nextDue).toISOString(),
};
if (recurrence === "CUSTOM") body.intervalDays = parseInt(intervalDays);
if (recurrence === "YEARLY") body.month = parseInt(month);
setBusy(true);
const res = await fetch("/api/v1/tasks", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
setBusy(false);
if (!res.ok) { toast({ title: "Error", description: "Could not schedule.", variant: "destructive" }); return; }
toast({ title: "Maintenance scheduled", description: "It'll show in Reminders too." });
setOpen(false);
router.refresh();
}
return (
<>
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
<Wrench className="h-4 w-4 mr-1" /> Schedule maintenance
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader><DialogTitle>Schedule maintenance</DialogTitle></DialogHeader>
<form onSubmit={submit} className="space-y-4 pt-1">
<div className="space-y-1.5">
<Label>What needs doing?</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} autoFocus />
</div>
<div className="space-y-1.5">
<Label>Repeats</Label>
<Select value={recurrence} onValueChange={setRecurrence}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ONCE">Just once</SelectItem>
<SelectItem value="CUSTOM">Every N days</SelectItem>
<SelectItem value="MONTHLY">Every month</SelectItem>
<SelectItem value="YEARLY">Every year</SelectItem>
</SelectContent>
</Select>
</div>
{recurrence === "CUSTOM" && (
<div className="space-y-1.5">
<Label>Every how many days?</Label>
<Input type="number" min="1" value={intervalDays} onChange={(e) => setIntervalDays(e.target.value)} />
</div>
)}
{recurrence === "YEARLY" && (
<div className="space-y-1.5">
<Label>Which month?</Label>
<Select value={month} onValueChange={setMonth}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{MONTH_NAMES.map((n, i) => <SelectItem key={i + 1} value={String(i + 1)}>{n}</SelectItem>)}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1.5">
<Label>First due date</Label>
<Input type="date" value={nextDue} onChange={(e) => setNextDue(e.target.value)} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Schedule"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -2,7 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw, BookOpen, KeyRound, Package, PawPrint } from "lucide-react"; import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw, BookOpen, KeyRound, Package, PawPrint, MapPin } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const navItems = [ const navItems = [
@@ -11,6 +11,7 @@ const navItems = [
{ label: "Plant types", href: "/garden/types", icon: BookOpen }, { label: "Plant types", href: "/garden/types", icon: BookOpen },
{ label: "Inventory", href: "/inventory", icon: Package }, { label: "Inventory", href: "/inventory", icon: Package },
{ label: "Animals", href: "/animals", icon: PawPrint }, { label: "Animals", href: "/animals", icon: PawPrint },
{ label: "Locations", href: "/locations", icon: MapPin },
{ label: "Reminders", href: "/tasks", icon: Bell }, { label: "Reminders", href: "/tasks", icon: Bell },
// Home maintenance and Equipment come in future releases // Home maintenance and Equipment come in future releases
// { label: "Home", href: "/home", icon: Home }, // { label: "Home", href: "/home", icon: Home },

View File

@@ -0,0 +1,176 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/hooks/use-toast";
import { Plus, MoreVertical, MapPin, Building2, DoorOpen, Box, Trees, HelpCircle } from "lucide-react";
import { buildTree, flattenForSelect } from "@/lib/locations/tree";
type Loc = {
id: string;
name: string;
kind: string;
parentId: string | null;
_count: { plants: number; items: number };
};
const KIND_LABELS: Record<string, string> = {
AREA: "Area", BUILDING: "Building", ROOM: "Room", STORAGE: "Storage", OTHER: "Other",
};
const KIND_ICONS: Record<string, typeof MapPin> = {
AREA: Trees, BUILDING: Building2, ROOM: DoorOpen, STORAGE: Box, OTHER: HelpCircle,
};
const NONE = "__none__";
export function LocationManager({ locations }: { locations: Loc[] }) {
const [dialog, setDialog] = useState<null | { mode: "add" | "edit"; id?: string; name: string; kind: string; parentId: string }>(null);
const [busy, setBusy] = useState(false);
const router = useRouter();
const { toast } = useToast();
const tree = buildTree(locations);
const flat = flattenForSelect(tree); // depth-ordered for indentation
function openAdd(parentId?: string) {
setDialog({ mode: "add", name: "", kind: "AREA", parentId: parentId ?? "" });
}
function openEdit(loc: Loc) {
setDialog({ mode: "edit", id: loc.id, name: loc.name, kind: loc.kind, parentId: loc.parentId ?? "" });
}
async function save() {
if (!dialog || !dialog.name.trim()) return;
setBusy(true);
const body = { name: dialog.name.trim(), kind: dialog.kind, parentId: dialog.parentId || null };
const res = await fetch(dialog.mode === "add" ? "/api/locations" : `/api/locations/${dialog.id}`, {
method: dialog.mode === "add" ? "POST" : "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setBusy(false);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast({ title: "Error", description: err.error ?? "Could not save.", variant: "destructive" });
return;
}
toast({ title: dialog.mode === "add" ? "Location added" : "Saved" });
setDialog(null);
router.refresh();
}
async function remove(loc: Loc) {
if (!confirm(`Delete "${loc.name}"?`)) return;
const res = await fetch(`/api/locations/${loc.id}`, { method: "DELETE" });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast({ title: "Can't delete", description: err.error ?? "Move its contents first.", variant: "destructive" });
return;
}
toast({ title: "Deleted" });
router.refresh();
}
// Valid parents for the dialog (exclude self when editing — server guards descendants).
const parentChoices = flat.filter((n) => !(dialog?.mode === "edit" && n.id === dialog.id));
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
The shared place tree garden areas, buildings, rooms, and storage. Plants and items live here.
</p>
<Button size="sm" onClick={() => openAdd()}><Plus className="h-4 w-4 mr-1" />Add location</Button>
</div>
{locations.length === 0 ? (
<div className="text-center py-12 text-muted-foreground border rounded-lg">
<MapPin className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p className="text-sm">No locations yet.</p>
</div>
) : (
<div className="divide-y border rounded-lg overflow-hidden">
{flat.map((n) => {
const Icon = KIND_ICONS[n.kind] ?? MapPin;
const counts = (n as unknown as Loc)._count;
return (
<div key={n.id} className="flex items-center gap-2 px-3 py-2.5" style={{ paddingLeft: n.depth * 20 + 12 }}>
<Icon className="h-4 w-4 text-[hsl(var(--leaf))] shrink-0" />
<span className="font-medium text-sm">{n.name}</span>
<Badge variant="outline" className="text-[10px]">{KIND_LABELS[n.kind] ?? n.kind}</Badge>
<span className="text-xs text-muted-foreground">
{[counts?.plants ? `${counts.plants} plants` : "", counts?.items ? `${counts.items} items` : ""].filter(Boolean).join(" · ")}
</span>
<div className="ml-auto">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"><MoreVertical className="h-4 w-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openAdd(n.id)}>Add sub-location</DropdownMenuItem>
<DropdownMenuItem onClick={() => openEdit(n as unknown as Loc)}>Rename / move</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-[hsl(var(--ember))]" onClick={() => remove(n as unknown as Loc)}>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
})}
</div>
)}
<Dialog open={!!dialog} onOpenChange={(v) => { if (!v) setDialog(null); }}>
<DialogContent className="max-w-sm">
<DialogHeader><DialogTitle>{dialog?.mode === "add" ? "Add location" : "Edit location"}</DialogTitle></DialogHeader>
{dialog && (
<form onSubmit={(e) => { e.preventDefault(); save(); }} className="space-y-4">
<div className="space-y-1.5">
<Label>Name</Label>
<Input value={dialog.name} onChange={(e) => setDialog({ ...dialog, name: e.target.value })} autoFocus placeholder="Back garden, Kitchen, Shelf B…" />
</div>
<div className="space-y-1.5">
<Label>Type</Label>
<Select value={dialog.kind} onValueChange={(v) => setDialog({ ...dialog, kind: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(KIND_LABELS).map(([k, l]) => <SelectItem key={k} value={k}>{l}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Inside</Label>
<Select value={dialog.parentId || NONE} onValueChange={(v) => setDialog({ ...dialog, parentId: v === NONE ? "" : v })}>
<SelectTrigger><SelectValue placeholder="Top level" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}> Top level</SelectItem>
{parentChoices.map((p) => (
<SelectItem key={p.id} value={p.id}>{" ".repeat(p.depth * 2)}{p.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDialog(null)}>Cancel</Button>
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save"}</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -14,6 +14,8 @@ export const taskCreateInput = z.object({
notes: z.string().optional(), notes: z.string().optional(),
category: z.enum(["GARDEN", "HOME", "EQUIPMENT", "OTHER"]).default("GARDEN"), category: z.enum(["GARDEN", "HOME", "EQUIPMENT", "OTHER"]).default("GARDEN"),
plantId: z.string().optional(), plantId: z.string().optional(),
itemId: z.string().optional(),
animalId: z.string().optional(),
recurrence: z.enum(["ONCE", "CUSTOM", "MONTHLY", "YEARLY"]).default("ONCE"), recurrence: z.enum(["ONCE", "CUSTOM", "MONTHLY", "YEARLY"]).default("ONCE"),
intervalDays: z.number().int().positive().optional(), intervalDays: z.number().int().positive().optional(),
month: z.number().int().min(1).max(12).optional(), month: z.number().int().min(1).max(12).optional(),
@@ -34,10 +36,19 @@ export const itemCreateInput = z.object({
parentItemId: z.string().nullable().optional(), parentItemId: z.string().nullable().optional(),
value: z.coerce.number().nonnegative().nullable().optional(), value: z.coerce.number().nonnegative().nullable().optional(),
purchaseDate: z.string().nullable().optional(), purchaseDate: z.string().nullable().optional(),
purchaseFrom: z.string().optional(),
warrantyExpiry: z.string().nullable().optional(), warrantyExpiry: z.string().nullable().optional(),
warrantyDetails: z.string().optional(),
lifetimeWarranty: z.boolean().optional(),
insured: z.boolean().optional(),
serial: z.string().optional(), serial: z.string().optional(),
modelNumber: z.string().optional(), modelNumber: z.string().optional(),
brand: 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(), barcode: z.string().optional(),
notes: z.string().optional(), notes: z.string().optional(),
imageUrl: z.string().nullable().optional(), imageUrl: z.string().nullable().optional(),

View File

@@ -8,6 +8,22 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: 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",
changes: [
"Inventory got a lot deeper: attach manuals, receipts, and warranty docs (not just photos) to an item; record where you bought it, warranty details, lifetime-warranty and insured flags; and schedule recurring maintenance right from an item (it shows up in Reminders, with overdue flagged).",
"New Locations page in the menu — see and manage your whole place tree in one spot: add, rename, move, or remove garden areas, rooms, and storage, nested as deep as you like.",
],
},
{ {
version: "0.14.0", version: "0.14.0",
date: "2026-06-16", 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

@@ -82,10 +82,19 @@ export async function createItem(ctx: AuthContext, input: ItemCreateInput) {
parentItemId: r.parentItemId || null, parentItemId: r.parentItemId || null,
value: r.value ?? null, value: r.value ?? null,
purchaseDate: toDate(r.purchaseDate), purchaseDate: toDate(r.purchaseDate),
purchaseFrom: r.purchaseFrom?.trim() || null,
warrantyExpiry: toDate(r.warrantyExpiry), warrantyExpiry: toDate(r.warrantyExpiry),
warrantyDetails: r.warrantyDetails?.trim() || null,
lifetimeWarranty: r.lifetimeWarranty ?? false,
insured: r.insured ?? false,
serial: r.serial?.trim() || null, serial: r.serial?.trim() || null,
modelNumber: r.modelNumber?.trim() || null, modelNumber: r.modelNumber?.trim() || null,
brand: r.brand?.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, barcode: r.barcode?.trim() || null,
notes: r.notes?.trim() || null, notes: r.notes?.trim() || null,
imageUrl: r.imageUrl || null, imageUrl: r.imageUrl || null,
@@ -121,10 +130,20 @@ export async function updateItem(ctx: AuthContext, id: string, input: ItemUpdate
if (r.parentItemId !== undefined) data.parentItem = r.parentItemId ? { connect: { id: r.parentItemId } } : { disconnect: true }; if (r.parentItemId !== undefined) data.parentItem = r.parentItemId ? { connect: { id: r.parentItemId } } : { disconnect: true };
if (r.value !== undefined) data.value = r.value; if (r.value !== undefined) data.value = r.value;
if (r.purchaseDate !== undefined) data.purchaseDate = toDate(r.purchaseDate); if (r.purchaseDate !== undefined) data.purchaseDate = toDate(r.purchaseDate);
if (r.purchaseFrom !== undefined) data.purchaseFrom = r.purchaseFrom?.trim() || null;
if (r.warrantyExpiry !== undefined) data.warrantyExpiry = toDate(r.warrantyExpiry); if (r.warrantyExpiry !== undefined) data.warrantyExpiry = toDate(r.warrantyExpiry);
if (r.warrantyDetails !== undefined) data.warrantyDetails = r.warrantyDetails?.trim() || null;
if (r.lifetimeWarranty !== undefined) data.lifetimeWarranty = r.lifetimeWarranty;
if (r.insured !== undefined) data.insured = r.insured;
if (r.serial !== undefined) data.serial = r.serial?.trim() || null; if (r.serial !== undefined) data.serial = r.serial?.trim() || null;
if (r.modelNumber !== undefined) data.modelNumber = r.modelNumber?.trim() || null; if (r.modelNumber !== undefined) data.modelNumber = r.modelNumber?.trim() || null;
if (r.brand !== undefined) data.brand = r.brand?.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.barcode !== undefined) data.barcode = r.barcode?.trim() || null;
if (r.notes !== undefined) data.notes = r.notes?.trim() || null; if (r.notes !== undefined) data.notes = r.notes?.trim() || null;
if (r.imageUrl !== undefined) data.imageUrl = r.imageUrl || null; if (r.imageUrl !== undefined) data.imageUrl = r.imageUrl || null;

View File

@@ -18,6 +18,8 @@ export async function createTask(ctx: AuthContext, input: TaskCreateInput) {
notes: input.notes?.trim() || null, notes: input.notes?.trim() || null,
category: input.category, category: input.category,
plantId: input.plantId || null, plantId: input.plantId || null,
itemId: input.itemId || null,
animalId: input.animalId || null,
recurrence: input.recurrence, recurrence: input.recurrence,
intervalDays: input.intervalDays ?? null, intervalDays: input.intervalDays ?? null,
month: input.month ?? null, month: input.month ?? null,

View File

@@ -1,2 +1,2 @@
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`. // Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
export const APP_VERSION = "0.14.0"; export const APP_VERSION = "0.16.0";