Compare commits
2 Commits
cffa739056
...
5f4c482636
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f4c482636 | |||
| 63569aabd3 |
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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?
|
||||||
|
|||||||
@@ -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 “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 && (
|
{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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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";
|
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.
|
||||||
|
|||||||
22
src/app/api/v1/items/import/route.ts
Normal file
22
src/app/api/v1/items/import/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
134
src/components/inventory/import-button.tsx
Normal file
134
src/components/inventory/import-button.tsx
Normal 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'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'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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)} />
|
||||||
|
|||||||
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 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 },
|
||||||
|
|||||||
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(),
|
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(),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
61
src/lib/inventory/homebox-import.test.ts
Normal file
61
src/lib/inventory/homebox-import.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
131
src/lib/inventory/homebox-import.ts
Normal file
131
src/lib/inventory/homebox-import.ts
Normal 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);
|
||||||
|
}
|
||||||
141
src/lib/services/inventory-import.ts
Normal file
141
src/lib/services/inventory-import.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user