Files
Moonbase/src/components/inventory/item-dialog.tsx
tonym 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

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>} />
);
}