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>
This commit is contained in:
2026-06-16 00:34:07 -05:00
parent cffa739056
commit 63569aabd3
15 changed files with 481 additions and 21 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

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

View File

@@ -5,13 +5,16 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
ChevronLeft, MapPin, Box, Package, CalendarDays, DollarSign, Hash, Tag, FileText, Barcode,
Store, ShieldCheck, Wrench, AlertCircle,
} from "lucide-react";
import { formatDate } from "@/lib/utils";
import { warrantyStatus, WARRANTY_LABELS } from "@/lib/inventory/value";
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 { DeleteItemButton } from "@/components/inventory/delete-item-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";
function toDateInput(d: Date | null): string {
@@ -34,6 +37,7 @@ export default async function ItemDetailPage({ params }: { params: { id: string
attachments: { orderBy: { createdAt: "asc" } },
logs: { orderBy: { date: "desc" } },
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 } }),
@@ -43,7 +47,8 @@ export default async function ItemDetailPage({ params }: { params: { id: string
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 docs = item.attachments.filter((a) => a.kind !== "PHOTO");
@@ -60,7 +65,11 @@ export default async function ItemDetailPage({ params }: { params: { id: string
serial: item.serial ?? "",
value: item.value != null ? String(Number(item.value)) : "",
purchaseDate: toDateInput(item.purchaseDate),
purchaseFrom: item.purchaseFrom ?? "",
warrantyExpiry: toDateInput(item.warrantyExpiry),
warrantyDetails: item.warrantyDetails ?? "",
lifetimeWarranty: item.lifetimeWarranty,
insured: item.insured,
barcode: item.barcode ?? "",
notes: item.notes ?? "",
labelIds: item.labels.map((l) => l.labelId),
@@ -72,6 +81,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 (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.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.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 });
@@ -87,14 +99,15 @@ export default async function ItemDetailPage({ params }: { params: { id: string
<h1 className="font-display text-2xl font-semibold">{item.name}</h1>
{item.description && <p className="text-sm text-muted-foreground">{item.description}</p>}
</div>
{wStatus !== "none" && (
<Badge variant="outline" className="ml-auto shrink-0">{WARRANTY_LABELS[wStatus]}</Badge>
{(item.lifetimeWarranty || wStatus !== "none") && (
<Badge variant="outline" className="ml-auto shrink-0">{warrantyLabel}</Badge>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<EditItemButton item={editFields} locations={locations} items={allItems.filter((i) => i.id !== item.id)} labels={labels} />
<AddItemLogButton itemId={item.id} />
<ScheduleMaintenanceButton itemId={item.id} itemName={item.name} />
<AttachmentUpload itemId={item.id} />
<DeleteItemButton id={item.id} name={item.name} />
</div>
@@ -134,6 +147,44 @@ export default async function ItemDetailPage({ params }: { params: { id: string
<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>
)}
<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 && (
<div className="space-y-1.5">
<h2 className="font-display text-sm font-semibold">Documents</h2>

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";
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
// under public/uploads/items (a persistent Docker volume), same as suggestions.

View File

@@ -3,33 +3,61 @@
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
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 }) {
const [busy, setBusy] = useState(false);
const [kind, setKind] = useState("PHOTO");
const inputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
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) {
setBusy(true);
const form = new FormData();
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 });
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" });
}
const accept = KINDS.find((k) => k.value === kind)?.accept ?? "*";
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 = ""; }} />
<Button size="sm" variant="outline" disabled={busy} onClick={() => inputRef.current?.click()}>
<ImagePlus className="h-4 w-4 mr-1" /> {busy ? "Uploading…" : "Add photo"}
<DropdownMenu>
<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>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{KINDS.map((k) => (
<DropdownMenuItem key={k.value} onClick={() => pick(k.value)}>{k.label}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</>
);
}

View File

@@ -33,7 +33,11 @@ export type ItemFields = {
serial: string;
value: string;
purchaseDate: string;
purchaseFrom: string;
warrantyExpiry: string;
warrantyDetails: string;
lifetimeWarranty: boolean;
insured: boolean;
barcode: string;
notes: string;
labelIds: string[];
@@ -42,7 +46,8 @@ export type ItemFields = {
export function blankItem(): ItemFields {
return {
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,
barcode: "", notes: "", labelIds: [],
};
}
@@ -109,7 +114,11 @@ function ItemDialog({
serial: f.serial.trim() || undefined,
value: f.value ? Number(f.value) : null,
purchaseDate: f.purchaseDate || null,
purchaseFrom: f.purchaseFrom.trim() || undefined,
warrantyExpiry: f.warrantyExpiry || null,
warrantyDetails: f.warrantyDetails.trim() || undefined,
lifetimeWarranty: f.lifetimeWarranty,
insured: f.insured,
barcode: f.barcode.trim() || undefined,
notes: f.notes.trim() || undefined,
labelIds: f.labelIds,
@@ -197,7 +206,26 @@ function ItemDialog({
</div>
<div className="space-y-1.5">
<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 className="col-span-2 space-y-1.5">

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 { 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";
const navItems = [
@@ -11,6 +11,7 @@ const navItems = [
{ label: "Plant types", href: "/garden/types", icon: BookOpen },
{ label: "Inventory", href: "/inventory", icon: Package },
{ label: "Animals", href: "/animals", icon: PawPrint },
{ label: "Locations", href: "/locations", icon: MapPin },
{ label: "Reminders", href: "/tasks", icon: Bell },
// Home maintenance and Equipment come in future releases
// { 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(),
category: z.enum(["GARDEN", "HOME", "EQUIPMENT", "OTHER"]).default("GARDEN"),
plantId: z.string().optional(),
itemId: z.string().optional(),
animalId: z.string().optional(),
recurrence: z.enum(["ONCE", "CUSTOM", "MONTHLY", "YEARLY"]).default("ONCE"),
intervalDays: z.number().int().positive().optional(),
month: z.number().int().min(1).max(12).optional(),
@@ -34,7 +36,11 @@ export const itemCreateInput = z.object({
parentItemId: z.string().nullable().optional(),
value: z.coerce.number().nonnegative().nullable().optional(),
purchaseDate: z.string().nullable().optional(),
purchaseFrom: z.string().optional(),
warrantyExpiry: z.string().nullable().optional(),
warrantyDetails: z.string().optional(),
lifetimeWarranty: z.boolean().optional(),
insured: z.boolean().optional(),
serial: z.string().optional(),
modelNumber: z.string().optional(),
brand: z.string().optional(),

View File

@@ -8,6 +8,14 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
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",
date: "2026-06-16",

View File

@@ -82,7 +82,11 @@ export async function createItem(ctx: AuthContext, input: ItemCreateInput) {
parentItemId: r.parentItemId || null,
value: r.value ?? null,
purchaseDate: toDate(r.purchaseDate),
purchaseFrom: r.purchaseFrom?.trim() || null,
warrantyExpiry: toDate(r.warrantyExpiry),
warrantyDetails: r.warrantyDetails?.trim() || null,
lifetimeWarranty: r.lifetimeWarranty ?? false,
insured: r.insured ?? false,
serial: r.serial?.trim() || null,
modelNumber: r.modelNumber?.trim() || null,
brand: r.brand?.trim() || null,
@@ -121,7 +125,11 @@ 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.value !== undefined) data.value = r.value;
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.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.modelNumber !== undefined) data.modelNumber = r.modelNumber?.trim() || null;
if (r.brand !== undefined) data.brand = r.brand?.trim() || null;

View File

@@ -18,6 +18,8 @@ export async function createTask(ctx: AuthContext, input: TaskCreateInput) {
notes: input.notes?.trim() || null,
category: input.category,
plantId: input.plantId || null,
itemId: input.itemId || null,
animalId: input.animalId || null,
recurrence: input.recurrence,
intervalDays: input.intervalDays ?? 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`.
export const APP_VERSION = "0.14.0";
export const APP_VERSION = "0.15.0";