From 8f77b7c4531c59b50f92af9ff24e8a59e7a2df10 Mon Sep 17 00:00:00 2001 From: tonym Date: Sat, 4 Jul 2026 20:08:50 -0500 Subject: [PATCH] =?UTF-8?q?v0.22.0=20=E2=80=94=20Inventory=20search=20+=20?= =?UTF-8?q?Location/Label=20grouping;=20Suggestions=20styling=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a search box (matches name, location, and labels) and a Location/Label grouping toggle to Inventory, and fixes the unstyled Suggestions page. (Renumbered from 0.19.0 → 0.22.0: Pantry work took 0.19–0.21 in parallel.) Hardening from a max-effort code review of the new grid: - Contained items resolve to their container's effective location, so search finds them and the flat view groups them under the right place (not "No location"). - Location/label groups key on id, not name, so same-named places/labels stay separate; synthetic bucket renamed "No labels" to avoid colliding with a real label; fixed duplicate React keys and two empty-state regressions. - deleteItem now reparents a deleted container's contents into its own spot (its place or parent item) instead of orphaning them; the grid also treats any still-orphaned child as top-level so nothing silently vanishes. Co-Authored-By: Claude Opus 4.8 --- src/app/(dashboard)/suggestions/page.tsx | 3 +- src/app/globals.css | 6 + src/components/inventory/inventory-grid.tsx | 290 +++++++++++++++++--- src/lib/changelog.ts | 10 + src/lib/services/items.ts | 24 +- src/lib/version.ts | 2 +- tailwind.config.ts | 4 + 7 files changed, 299 insertions(+), 40 deletions(-) diff --git a/src/app/(dashboard)/suggestions/page.tsx b/src/app/(dashboard)/suggestions/page.tsx index 81f5f66..67dbf14 100644 --- a/src/app/(dashboard)/suggestions/page.tsx +++ b/src/app/(dashboard)/suggestions/page.tsx @@ -11,8 +11,9 @@ export default async function SuggestionsPage() { if (!session) redirect("/login"); // The panel uses light-gray (neutral-*) text that assumes a white backdrop, so // render it on a white card for readable contrast in light or dark theme. + // color-scheme:light keeps its native inputs/textarea light-filled too. return ( -
+
); diff --git a/src/app/globals.css b/src/app/globals.css index d16406d..a218b74 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -14,6 +14,11 @@ @layer base { :root { + /* Force native form controls (input/textarea/select) to the light theme so + * they don't pick up the OS dark fill — otherwise an unstyled renders + * with a dark system background in a dark-mode browser, hiding placeholder + * text. .dark overrides this below. */ + color-scheme: light; --dew: 120 18% 94%; /* #EEF3EE — pale sage background */ --dew-deep: 120 20% 97%; /* slightly lighter for cards */ --soil: 25 35% 18%; /* #3A2B1A — warm dark brown text */ @@ -58,6 +63,7 @@ } .dark { + color-scheme: dark; --dew: 140 18% 10%; --dew-deep: 140 18% 12%; --soil: 120 18% 88%; diff --git a/src/components/inventory/inventory-grid.tsx b/src/components/inventory/inventory-grid.tsx index 650313e..507d748 100644 --- a/src/components/inventory/inventory-grid.tsx +++ b/src/components/inventory/inventory-grid.tsx @@ -1,10 +1,11 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import Link from "next/link"; import { Badge } from "@/components/ui/badge"; import { Package, MapPin, ChevronDown, ChevronRight, Box, ShieldCheck, ShieldAlert, ShieldX, + Search, Tag, } from "lucide-react"; import { warrantyStatus } from "@/lib/inventory/value"; @@ -37,6 +38,31 @@ function WarrantyBadge({ expiry }: { expiry: string | Date | null }) { return {map.label}; } +function ItemThumb({ item, hasKids }: { item: GridItem; hasKids: boolean }) { + if (item.imageUrl) { + // eslint-disable-next-line @next/next/no-img-element + return ; + } + return ( + + {hasKids ? : } + + ); +} + +function ItemBadges({ item }: { item: GridItem }) { + return ( +
+ {item.labels.slice(0, 3).map((l) => ( + {l.label.name} + ))} + +
+ ); +} + +// Tree row — used when grouping by location with no active search. Shows nested +// container contents under an expand toggle. function ItemRow({ item, childrenByParent, depth }: { item: GridItem; childrenByParent: Map; @@ -55,14 +81,7 @@ function ItemRow({ item, childrenByParent, depth }: { ) : } - {item.imageUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : ( - - {kids.length > 0 ? : } - - )} + 0} /> {item.name} @@ -70,12 +89,7 @@ function ItemRow({ item, childrenByParent, depth }: { {kids.length > 0 && · holds {kids.length}} -
- {item.labels.slice(0, 3).map((l) => ( - {l.label.name} - ))} - -
+
{open && kids.map((k) => ( @@ -84,7 +98,101 @@ function ItemRow({ item, childrenByParent, depth }: { ); } +// Flat row — used for search results and label grouping, where the container +// tree doesn't apply. Shows a location/container hint instead of nesting. +function FlatRow({ item, hint }: { item: GridItem; hint: string | null }) { + const qty = Number(item.quantity); + return ( +
+ + 0} /> + + {item.name} + {qty !== 1 && · {qty}{item.unit ? ` ${item.unit}` : ""}} + {hint && {hint}} + + +
+ ); +} + +function Group({ icon: Icon, name, count, children }: { + icon: typeof MapPin; name: string; count: number; children: React.ReactNode; +}) { + return ( +
+
+ + {name} + · {count} +
+
{children}
+
+ ); +} + +type GroupBy = "location" | "label"; + +// The place an item physically sits in. An item lives directly in a Place, or +// inside another Item that does — so for contained items (own location null) we +// walk up the container chain to the nearest ancestor that has a location. The +// `seen` guard keeps a corrupt cycle from looping forever. +function effectiveLocation( + it: GridItem, + byId: Map, +): { id: string; name: string } | null { + const seen = new Set(); + let cur: GridItem | undefined = it; + while (cur) { + if (cur.location) return cur.location; + if (!cur.parentItemId || seen.has(cur.id)) break; + seen.add(cur.id); + cur = byId.get(cur.parentItemId); + } + return null; +} + export function InventoryGrid({ items }: { items: GridItem[] }) { + const [query, setQuery] = useState(""); + const [groupBy, setGroupBy] = useState("location"); + + const q = query.trim().toLowerCase(); + + // Item lookup by id — powers the "inside X" hint and the container-chain walk + // that resolves a contained item's effective location. + const byId = useMemo(() => { + const m = new Map(); + for (const it of items) m.set(it.id, it); + return m; + }, [items]); + + // Match name, effective location, or any label so search finds things by where + // they live (including inside a container) or how they're tagged, not just by name. + const matched = useMemo( + () => + items.filter((it) => { + if (!q) return true; + const loc = effectiveLocation(it, byId); + return ( + it.name.toLowerCase().includes(q) || + (loc?.name.toLowerCase().includes(q) ?? false) || + it.labels.some((l) => l.label.name.toLowerCase().includes(q)) + ); + }), + [items, q, byId], + ); + + // Container nesting only makes sense in the unfiltered location tree; a search + // or a label view shows a flat list, so we tree only in that one case. + const treeView = groupBy === "location" && !q; + + const hintFor = (it: GridItem): string | null => { + const parent = it.parentItemId ? byId.get(it.parentItemId) : undefined; + if (parent) return `in ${parent.name}`; + return it.location ? it.location.name : null; + }; + + // Zero inventory: show just the empty state, no search/toggle chrome to operate on. if (items.length === 0) { return (
@@ -95,7 +203,55 @@ export function InventoryGrid({ items }: { items: GridItem[] }) { ); } - // Items contained inside another item are rendered under their parent. + const empty = ( +
+ +

Nothing matches

+

Try a different search or grouping.

+
+ ); + + return ( +
+
+
+ + setQuery(e.target.value)} + placeholder="Search items, locations, or labels…" + className="w-full rounded-md border bg-background pl-9 pr-3 py-2 text-sm" + /> +
+
+ {(["location", "label"] as const).map((g) => ( + + ))} +
+
+ + {matched.length === 0 ? empty : treeView ? ( + + ) : groupBy === "location" ? ( + + ) : ( + + )} +
+ ); +} + +// Default view: top-level items grouped by location, container contents nested. +function LocationTree({ items }: { items: GridItem[] }) { const childrenByParent = new Map(); for (const it of items) { if (it.parentItemId) { @@ -105,8 +261,11 @@ export function InventoryGrid({ items }: { items: GridItem[] }) { } } - // Top-level items (not inside another item) grouped by location. - const topLevel = items.filter((i) => !i.parentItemId); + // Top-level = items with no parent, plus "orphans" whose parent isn't in the + // set (e.g. a soft-deleted container) — otherwise those still-active children + // would render nowhere, since nothing left recurses into them. + const present = new Set(items.map((i) => i.id)); + const topLevel = items.filter((i) => !i.parentItemId || !present.has(i.parentItemId)); const groups = new Map(); const noLoc: GridItem[] = []; for (const it of topLevel) { @@ -115,30 +274,91 @@ export function InventoryGrid({ items }: { items: GridItem[] }) { g.items.push(it); groups.set(it.location.id, g); } - const sorted = Array.from(groups.values()).sort((a, b) => a.name.localeCompare(b.name)); + const sorted = Array.from(groups.entries()).sort((a, b) => a[1].name.localeCompare(b[1].name)); return (
- {sorted.map((g) => ( -
-
- - {g.name} - · {g.items.length} -
-
- {g.items.map((it) => )} -
-
+ {sorted.map(([id, g]) => ( + + {g.items.map((it) => )} + ))} - - {noLoc.length > 0 && ( -
- {sorted.length > 0 &&

No location

} + {noLoc.length > 0 && + (sorted.length > 0 ? ( + + {noLoc.map((it) => )} + + ) : (
{noLoc.map((it) => )}
-
+ ))} +
+ ); +} + +// Flat results grouped by location (every matching item is its own row). Groups +// key on the effective location id — so a contained item shows under its +// container's place (matching the tree view), and two same-named places stay +// separate instead of merging. +function FlatByLocation({ items, byId, hintFor }: { + items: GridItem[]; + byId: Map; + hintFor: (i: GridItem) => string | null; +}) { + const groups = new Map(); + const noLoc: GridItem[] = []; + for (const it of items) { + const loc = effectiveLocation(it, byId); + if (!loc) { noLoc.push(it); continue; } + const g = groups.get(loc.id) ?? { name: loc.name, items: [] }; + g.items.push(it); + groups.set(loc.id, g); + } + const sorted = Array.from(groups.entries()).sort((a, b) => a[1].name.localeCompare(b[1].name)); + return ( +
+ {sorted.map(([id, g]) => ( + + {g.items.map((it) => )} + + ))} + {noLoc.length > 0 && ( + + {noLoc.map((it) => )} + + )} +
+ ); +} + +// Flat results grouped by label. An item with multiple labels appears under each; +// items with no labels collect under a synthetic "No labels" group. Real-label +// groups key on label id, so two same-named labels stay separate and a user label +// named "No labels" can't merge into the synthetic bucket. +function FlatByLabel({ items, hintFor }: { items: GridItem[]; hintFor: (i: GridItem) => string | null }) { + const groups = new Map(); + const unlabeled: GridItem[] = []; + for (const it of items) { + if (it.labels.length === 0) { unlabeled.push(it); continue; } + for (const { label } of it.labels) { + const g = groups.get(label.id) ?? { name: label.name, items: [] }; + g.items.push(it); + groups.set(label.id, g); + } + } + const sorted = Array.from(groups.entries()).sort((a, b) => a[1].name.localeCompare(b[1].name)); + return ( +
+ {sorted.map(([id, g]) => ( + + {g.items.map((it) => )} + + ))} + {unlabeled.length > 0 && ( + + {unlabeled.map((it) => )} + )}
); diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index 8f1c8a6..75b00fd 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -8,6 +8,16 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.22.0", + date: "2026-07-04", + changes: [ + "Inventory can now be searched and regrouped — there's a search box (matches item names, locations, and labels) and a Location / Label toggle to group your stuff either way.", + "Fixed the Suggestions page — the boxes and the \"Send suggestion\" button were rendering unstyled (dark, unreadable inputs and an invisible button). They now look right and are readable.", + "Searching now finds things stored inside containers too — search \"garage\" and you'll see the drill inside the garage toolbox, not just the toolbox.", + "Deleting a container (like a toolbox) now leaves its contents in its spot — a wrench in a deleted garage toolbox becomes a wrench loose in the garage, instead of disappearing.", + ], + }, { version: "0.21.1", date: "2026-06-29", diff --git a/src/lib/services/items.ts b/src/lib/services/items.ts index 3c26046..1f608ad 100644 --- a/src/lib/services/items.ts +++ b/src/lib/services/items.ts @@ -164,11 +164,29 @@ export async function updateItem(ctx: AuthContext, id: string, input: ItemUpdate export async function deleteItem(ctx: AuthContext, id: string) { if (!isAdmin(ctx.role)) throw new ApiError(403, "Only admins can delete items"); - const item = await db.item.findUnique({ where: { id }, select: { active: true, name: true } }); + const item = await db.item.findUnique({ + where: { id }, + select: { active: true, name: true, locationId: true, parentItemId: true }, + }); if (!item || !item.active) throw new ApiError(404, "Item not found"); - await db.item.update({ where: { id }, data: { active: false } }); + // Deleting a container drops its contents into the container's own spot (its + // place, or the item it was nested in) instead of orphaning them — a wrench in + // a deleted garage toolbox becomes a wrench loose in the garage. + const reparented = await db.$transaction(async (tx) => { + const { count } = await tx.item.updateMany({ + where: { parentItemId: id, active: true }, + data: { locationId: item.locationId, parentItemId: item.parentItemId }, + }); + await tx.item.update({ where: { id }, data: { active: false } }); + return count; + }); await db.auditEvent.create({ - data: { action: "item.delete", entityId: id, actorId: ctx.userId, payload: { name: item.name, via: ctx.via } }, + data: { + action: "item.delete", + entityId: id, + actorId: ctx.userId, + payload: { name: item.name, reparentedChildren: reparented, via: ctx.via }, + }, }); } diff --git a/src/lib/version.ts b/src/lib/version.ts index 7c64986..1b5548b 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,2 +1,2 @@ // Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`. -export const APP_VERSION = "0.21.1"; +export const APP_VERSION = "0.22.0"; diff --git a/tailwind.config.ts b/tailwind.config.ts index e09b134..592d4fb 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -7,6 +7,10 @@ const config: Config = { "./components/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}", "./src/**/*.{ts,tsx}", + // The shared @otm/account-panel ships its own Tailwind classes (Suggestions, + // self-update, etc.). Scan its source so those utilities actually compile — + // otherwise the panel renders nearly unstyled (e.g. a bg-less Send button). + "./node_modules/@otm/account-panel/src/**/*.{ts,tsx}", ], theme: { container: {