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:
@@ -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 “Schedule maintenance” 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>
|
||||
|
||||
35
src/app/(dashboard)/locations/page.tsx
Normal file
35
src/app/(dashboard)/locations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"}
|
||||
</Button>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
108
src/components/inventory/schedule-maintenance-button.tsx
Normal file
108
src/components/inventory/schedule-maintenance-button.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
176
src/components/locations/location-manager.tsx
Normal file
176
src/components/locations/location-manager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user