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>
281 lines
12 KiB
TypeScript
281 lines
12 KiB
TypeScript
"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 { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { Plus, Pencil } from "lucide-react";
|
|
|
|
export type LocationOpt = { id: string; name: string };
|
|
export type ItemOpt = { id: string; name: string };
|
|
export type LabelOpt = { id: string; name: string; color: string | null };
|
|
|
|
export type ItemFields = {
|
|
id?: string;
|
|
name: string;
|
|
description: string;
|
|
quantity: string;
|
|
unit: string;
|
|
locationId: string;
|
|
parentItemId: string;
|
|
brand: string;
|
|
modelNumber: string;
|
|
serial: string;
|
|
value: string;
|
|
purchaseDate: string;
|
|
purchaseFrom: string;
|
|
warrantyExpiry: string;
|
|
warrantyDetails: string;
|
|
lifetimeWarranty: boolean;
|
|
insured: boolean;
|
|
barcode: string;
|
|
notes: string;
|
|
labelIds: string[];
|
|
};
|
|
|
|
export function blankItem(): ItemFields {
|
|
return {
|
|
name: "", description: "", quantity: "1", unit: "", locationId: "", parentItemId: "",
|
|
brand: "", modelNumber: "", serial: "", value: "", purchaseDate: "", purchaseFrom: "",
|
|
warrantyExpiry: "", warrantyDetails: "", lifetimeWarranty: false, insured: false,
|
|
barcode: "", notes: "", labelIds: [],
|
|
};
|
|
}
|
|
|
|
const NONE = "__none__";
|
|
|
|
function ItemDialog({
|
|
trigger, title, initial, locations, items, labels: initialLabels,
|
|
}: {
|
|
trigger: React.ReactNode;
|
|
title: string;
|
|
initial: ItemFields;
|
|
locations: LocationOpt[];
|
|
items: ItemOpt[];
|
|
labels: LabelOpt[];
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [f, setF] = useState<ItemFields>(initial);
|
|
const [labels, setLabels] = useState<LabelOpt[]>(initialLabels);
|
|
const [newLabel, setNewLabel] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const router = useRouter();
|
|
const { toast } = useToast();
|
|
|
|
function set<K extends keyof ItemFields>(k: K, v: ItemFields[K]) {
|
|
setF((p) => ({ ...p, [k]: v }));
|
|
}
|
|
function toggleLabel(id: string) {
|
|
setF((p) => ({ ...p, labelIds: p.labelIds.includes(id) ? p.labelIds.filter((x) => x !== id) : [...p.labelIds, id] }));
|
|
}
|
|
async function addLabel() {
|
|
const name = newLabel.trim();
|
|
if (!name) return;
|
|
const res = await fetch("/api/v1/labels", {
|
|
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }),
|
|
});
|
|
if (res.ok) {
|
|
const lab = await res.json();
|
|
setLabels((p) => (p.some((l) => l.id === lab.id) ? p : [...p, lab]));
|
|
toggleLabel(lab.id);
|
|
setNewLabel("");
|
|
}
|
|
}
|
|
|
|
// Items selectable as a parent — exclude self.
|
|
const parentOptions = items.filter((i) => i.id !== f.id);
|
|
|
|
async function submit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!f.name.trim()) { setError("Please give the item a name."); return; }
|
|
setError("");
|
|
setBusy(true);
|
|
|
|
const payload = {
|
|
name: f.name.trim(),
|
|
description: f.description.trim() || undefined,
|
|
quantity: f.quantity ? Number(f.quantity) : undefined,
|
|
unit: f.unit.trim() || undefined,
|
|
locationId: f.locationId || null,
|
|
parentItemId: f.parentItemId || null,
|
|
brand: f.brand.trim() || undefined,
|
|
modelNumber: f.modelNumber.trim() || undefined,
|
|
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,
|
|
};
|
|
|
|
const res = await fetch(f.id ? `/api/v1/items/${f.id}` : "/api/v1/items", {
|
|
method: f.id ? "PATCH" : "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
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: f.id ? "Saved!" : "Item added!" });
|
|
setOpen(false);
|
|
router.refresh();
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<span onClick={() => { setF(initial); setLabels(initialLabels); setOpen(true); }}>{trigger}</span>
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>
|
|
<form onSubmit={submit} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="col-span-2 space-y-1.5">
|
|
<Label>Name *</Label>
|
|
<Input value={f.name} onChange={(e) => set("name", e.target.value)} placeholder="Cordless drill" />
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label>Quantity</Label>
|
|
<Input type="number" min="0" step="any" value={f.quantity} onChange={(e) => set("quantity", e.target.value)} />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label>Unit</Label>
|
|
<Input value={f.unit} onChange={(e) => set("unit", e.target.value)} placeholder="each" />
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label>Location</Label>
|
|
<Select value={f.locationId || NONE} onValueChange={(v) => set("locationId", v === NONE ? "" : v)}>
|
|
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={NONE}>—</SelectItem>
|
|
{locations.map((l) => <SelectItem key={l.id} value={l.id}>{l.name}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label>Inside item</Label>
|
|
<Select value={f.parentItemId || NONE} onValueChange={(v) => set("parentItemId", v === NONE ? "" : v)}>
|
|
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={NONE}>— (not inside another item)</SelectItem>
|
|
{parentOptions.map((i) => <SelectItem key={i.id} value={i.id}>{i.name}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label>Brand</Label>
|
|
<Input value={f.brand} onChange={(e) => set("brand", e.target.value)} placeholder="DeWalt" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label>Model #</Label>
|
|
<Input value={f.modelNumber} onChange={(e) => set("modelNumber", e.target.value)} />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label>Serial #</Label>
|
|
<Input value={f.serial} onChange={(e) => set("serial", e.target.value)} />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label>Value ($)</Label>
|
|
<Input type="number" min="0" step="0.01" value={f.value} onChange={(e) => set("value", e.target.value)} />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label>Purchased</Label>
|
|
<Input type="date" value={f.purchaseDate} onChange={(e) => set("purchaseDate", e.target.value)} />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label>Warranty until</Label>
|
|
<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">
|
|
<Label>Labels</Label>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{labels.map((l) => (
|
|
<button key={l.id} type="button" onClick={() => toggleLabel(l.id)}>
|
|
<Badge variant={f.labelIds.includes(l.id) ? "default" : "outline"} className="cursor-pointer">
|
|
{l.name}
|
|
</Badge>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex gap-2 mt-1">
|
|
<Input value={newLabel} onChange={(e) => setNewLabel(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addLabel(); } }}
|
|
placeholder="New label…" className="h-8 text-sm" />
|
|
<Button type="button" size="sm" variant="outline" onClick={addLabel}>Add</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="col-span-2 space-y-1.5">
|
|
<Label>Notes</Label>
|
|
<Textarea rows={2} value={f.notes} onChange={(e) => set("notes", e.target.value)} />
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
|
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save"}</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function AddItemButton(props: { locations: LocationOpt[]; items: ItemOpt[]; labels: LabelOpt[] }) {
|
|
return (
|
|
<ItemDialog title="Add an item" initial={blankItem()} {...props}
|
|
trigger={<Button size="sm"><Plus className="h-4 w-4 mr-1" />Add item</Button>} />
|
|
);
|
|
}
|
|
|
|
export function EditItemButton(props: { item: ItemFields; locations: LocationOpt[]; items: ItemOpt[]; labels: LabelOpt[] }) {
|
|
const { item, ...rest } = props;
|
|
return (
|
|
<ItemDialog title="Edit item" initial={item} {...rest}
|
|
trigger={<Button variant="outline" size="sm"><Pencil className="h-4 w-4 mr-1" />Edit</Button>} />
|
|
);
|
|
}
|