v0.22.0 — Inventory search + Location/Label grouping; Suggestions styling fix

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 20:08:50 -05:00
parent dd7af9e8d4
commit 8f77b7c453
7 changed files with 299 additions and 40 deletions

View File

@@ -11,8 +11,9 @@ export default async function SuggestionsPage() {
if (!session) redirect("/login"); if (!session) redirect("/login");
// The panel uses light-gray (neutral-*) text that assumes a white backdrop, so // 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. // 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 ( return (
<div className="rounded-lg border bg-white text-neutral-900 p-4 md:p-6 shadow-sm"> <div className="rounded-lg border bg-white text-neutral-900 p-4 md:p-6 shadow-sm [color-scheme:light]">
<SuggestionsPanel /> <SuggestionsPanel />
</div> </div>
); );

View File

@@ -14,6 +14,11 @@
@layer base { @layer base {
:root { :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 <input> 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: 120 18% 94%; /* #EEF3EE — pale sage background */
--dew-deep: 120 20% 97%; /* slightly lighter for cards */ --dew-deep: 120 20% 97%; /* slightly lighter for cards */
--soil: 25 35% 18%; /* #3A2B1A — warm dark brown text */ --soil: 25 35% 18%; /* #3A2B1A — warm dark brown text */
@@ -58,6 +63,7 @@
} }
.dark { .dark {
color-scheme: dark;
--dew: 140 18% 10%; --dew: 140 18% 10%;
--dew-deep: 140 18% 12%; --dew-deep: 140 18% 12%;
--soil: 120 18% 88%; --soil: 120 18% 88%;

View File

@@ -1,10 +1,11 @@
"use client"; "use client";
import { useState } from "react"; import { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
Package, MapPin, ChevronDown, ChevronRight, Box, ShieldCheck, ShieldAlert, ShieldX, Package, MapPin, ChevronDown, ChevronRight, Box, ShieldCheck, ShieldAlert, ShieldX,
Search, Tag,
} from "lucide-react"; } from "lucide-react";
import { warrantyStatus } from "@/lib/inventory/value"; import { warrantyStatus } from "@/lib/inventory/value";
@@ -37,6 +38,31 @@ function WarrantyBadge({ expiry }: { expiry: string | Date | null }) {
return <Badge variant="outline" className={`gap-1 text-[10px] ${map.cls}`}><Icon className="h-2.5 w-2.5" />{map.label}</Badge>; return <Badge variant="outline" className={`gap-1 text-[10px] ${map.cls}`}><Icon className="h-2.5 w-2.5" />{map.label}</Badge>;
} }
function ItemThumb({ item, hasKids }: { item: GridItem; hasKids: boolean }) {
if (item.imageUrl) {
// eslint-disable-next-line @next/next/no-img-element
return <img src={item.imageUrl} alt="" className="h-8 w-8 rounded object-cover shrink-0" />;
}
return (
<span className="h-8 w-8 rounded bg-[hsl(var(--leaf)/.08)] flex items-center justify-center shrink-0">
{hasKids ? <Box className="h-4 w-4 text-[hsl(var(--leaf)/.5)]" /> : <Package className="h-4 w-4 text-[hsl(var(--leaf)/.5)]" />}
</span>
);
}
function ItemBadges({ item }: { item: GridItem }) {
return (
<div className="flex items-center gap-1.5 shrink-0">
{item.labels.slice(0, 3).map((l) => (
<Badge key={l.label.id} variant="secondary" className="text-[10px]">{l.label.name}</Badge>
))}
<WarrantyBadge expiry={item.warrantyExpiry} />
</div>
);
}
// Tree row — used when grouping by location with no active search. Shows nested
// container contents under an expand toggle.
function ItemRow({ item, childrenByParent, depth }: { function ItemRow({ item, childrenByParent, depth }: {
item: GridItem; item: GridItem;
childrenByParent: Map<string, GridItem[]>; childrenByParent: Map<string, GridItem[]>;
@@ -55,14 +81,7 @@ function ItemRow({ item, childrenByParent, depth }: {
</button> </button>
) : <span className="w-4 shrink-0" />} ) : <span className="w-4 shrink-0" />}
{item.imageUrl ? ( <ItemThumb item={item} hasKids={kids.length > 0} />
// eslint-disable-next-line @next/next/no-img-element
<img src={item.imageUrl} alt="" className="h-8 w-8 rounded object-cover shrink-0" />
) : (
<span className="h-8 w-8 rounded bg-[hsl(var(--leaf)/.08)] flex items-center justify-center shrink-0">
{kids.length > 0 ? <Box className="h-4 w-4 text-[hsl(var(--leaf)/.5)]" /> : <Package className="h-4 w-4 text-[hsl(var(--leaf)/.5)]" />}
</span>
)}
<Link href={`/inventory/${item.id}`} className="min-w-0 flex-1"> <Link href={`/inventory/${item.id}`} className="min-w-0 flex-1">
<span className="font-medium text-sm">{item.name}</span> <span className="font-medium text-sm">{item.name}</span>
@@ -70,12 +89,7 @@ function ItemRow({ item, childrenByParent, depth }: {
{kids.length > 0 && <span className="text-xs text-muted-foreground"> · holds {kids.length}</span>} {kids.length > 0 && <span className="text-xs text-muted-foreground"> · holds {kids.length}</span>}
</Link> </Link>
<div className="flex items-center gap-1.5 shrink-0"> <ItemBadges item={item} />
{item.labels.slice(0, 3).map((l) => (
<Badge key={l.label.id} variant="secondary" className="text-[10px]">{l.label.name}</Badge>
))}
<WarrantyBadge expiry={item.warrantyExpiry} />
</div>
</div> </div>
{open && kids.map((k) => ( {open && kids.map((k) => (
<ItemRow key={k.id} item={k} childrenByParent={childrenByParent} depth={depth + 1} /> <ItemRow key={k.id} item={k} childrenByParent={childrenByParent} depth={depth + 1} />
@@ -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 (
<div className="flex items-center gap-2 px-3 py-2.5 hover:bg-accent transition-colors">
<span className="w-4 shrink-0" />
<ItemThumb item={item} hasKids={item._count.containedItems > 0} />
<Link href={`/inventory/${item.id}`} className="min-w-0 flex-1">
<span className="font-medium text-sm">{item.name}</span>
{qty !== 1 && <span className="text-xs text-muted-foreground"> · {qty}{item.unit ? ` ${item.unit}` : ""}</span>}
{hint && <span className="block text-xs text-muted-foreground truncate">{hint}</span>}
</Link>
<ItemBadges item={item} />
</div>
);
}
function Group({ icon: Icon, name, count, children }: {
icon: typeof MapPin; name: string; count: number; children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium">
<Icon className="h-4 w-4 text-[hsl(var(--leaf))]" />
<span className="font-display">{name}</span>
<span className="text-xs text-muted-foreground font-normal">· {count}</span>
</div>
<div className="divide-y border rounded-lg overflow-hidden">{children}</div>
</div>
);
}
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<string, GridItem>,
): { id: string; name: string } | null {
const seen = new Set<string>();
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[] }) { export function InventoryGrid({ items }: { items: GridItem[] }) {
const [query, setQuery] = useState("");
const [groupBy, setGroupBy] = useState<GroupBy>("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<string, GridItem>();
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) { if (items.length === 0) {
return ( return (
<div className="text-center py-16 text-muted-foreground"> <div className="text-center py-16 text-muted-foreground">
@@ -95,7 +203,55 @@ export function InventoryGrid({ items }: { items: GridItem[] }) {
); );
} }
// Items contained inside another item are rendered under their parent. const empty = (
<div className="text-center py-16 text-muted-foreground">
<Package className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="font-medium">Nothing matches</p>
<p className="text-sm mt-1">Try a different search or grouping.</p>
</div>
);
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
value={query}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center rounded-md border p-0.5 text-sm shrink-0">
{(["location", "label"] as const).map((g) => (
<button
key={g}
onClick={() => setGroupBy(g)}
className={`flex items-center gap-1.5 rounded px-3 py-1.5 transition-colors ${
groupBy === g ? "bg-[hsl(var(--leaf))] text-white" : "text-muted-foreground hover:text-foreground"
}`}
>
{g === "location" ? <MapPin className="h-3.5 w-3.5" /> : <Tag className="h-3.5 w-3.5" />}
{g === "location" ? "Location" : "Label"}
</button>
))}
</div>
</div>
{matched.length === 0 ? empty : treeView ? (
<LocationTree items={matched} />
) : groupBy === "location" ? (
<FlatByLocation items={matched} byId={byId} hintFor={hintFor} />
) : (
<FlatByLabel items={matched} hintFor={hintFor} />
)}
</div>
);
}
// Default view: top-level items grouped by location, container contents nested.
function LocationTree({ items }: { items: GridItem[] }) {
const childrenByParent = new Map<string, GridItem[]>(); const childrenByParent = new Map<string, GridItem[]>();
for (const it of items) { for (const it of items) {
if (it.parentItemId) { if (it.parentItemId) {
@@ -105,8 +261,11 @@ export function InventoryGrid({ items }: { items: GridItem[] }) {
} }
} }
// Top-level items (not inside another item) grouped by location. // Top-level = items with no parent, plus "orphans" whose parent isn't in the
const topLevel = items.filter((i) => !i.parentItemId); // 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<string, { name: string; items: GridItem[] }>(); const groups = new Map<string, { name: string; items: GridItem[] }>();
const noLoc: GridItem[] = []; const noLoc: GridItem[] = [];
for (const it of topLevel) { for (const it of topLevel) {
@@ -115,30 +274,91 @@ export function InventoryGrid({ items }: { items: GridItem[] }) {
g.items.push(it); g.items.push(it);
groups.set(it.location.id, g); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{sorted.map((g) => ( {sorted.map(([id, g]) => (
<div key={g.name} className="space-y-1.5"> <Group key={id} icon={MapPin} name={g.name} count={g.items.length}>
<div className="flex items-center gap-1.5 text-sm font-medium"> {g.items.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
<MapPin className="h-4 w-4 text-[hsl(var(--leaf))]" /> </Group>
<span className="font-display">{g.name}</span>
<span className="text-xs text-muted-foreground font-normal">· {g.items.length}</span>
</div>
<div className="divide-y border rounded-lg overflow-hidden">
{g.items.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
</div>
</div>
))} ))}
{noLoc.length > 0 &&
{noLoc.length > 0 && ( (sorted.length > 0 ? (
<div className="space-y-1.5"> <Group icon={MapPin} name="No location" count={noLoc.length}>
{sorted.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location</p>} {noLoc.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
</Group>
) : (
<div className="divide-y border rounded-lg overflow-hidden"> <div className="divide-y border rounded-lg overflow-hidden">
{noLoc.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)} {noLoc.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
</div> </div>
</div> ))}
</div>
);
}
// 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<string, GridItem>;
hintFor: (i: GridItem) => string | null;
}) {
const groups = new Map<string, { name: string; items: GridItem[] }>();
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 (
<div className="space-y-6">
{sorted.map(([id, g]) => (
<Group key={id} icon={MapPin} name={g.name} count={g.items.length}>
{g.items.map((it) => <FlatRow key={it.id} item={it} hint={it.parentItemId ? hintFor(it) : null} />)}
</Group>
))}
{noLoc.length > 0 && (
<Group icon={MapPin} name="No location" count={noLoc.length}>
{noLoc.map((it) => <FlatRow key={it.id} item={it} hint={it.parentItemId ? hintFor(it) : null} />)}
</Group>
)}
</div>
);
}
// 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<string, { name: string; items: GridItem[] }>();
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 (
<div className="space-y-6">
{sorted.map(([id, g]) => (
<Group key={id} icon={Tag} name={g.name} count={g.items.length}>
{g.items.map((it) => <FlatRow key={it.id} item={it} hint={hintFor(it)} />)}
</Group>
))}
{unlabeled.length > 0 && (
<Group icon={Tag} name="No labels" count={unlabeled.length}>
{unlabeled.map((it) => <FlatRow key={it.id} item={it} hint={hintFor(it)} />)}
</Group>
)} )}
</div> </div>
); );

View File

@@ -8,6 +8,16 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: 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", version: "0.21.1",
date: "2026-06-29", date: "2026-06-29",

View File

@@ -164,11 +164,29 @@ export async function updateItem(ctx: AuthContext, id: string, input: ItemUpdate
export async function deleteItem(ctx: AuthContext, id: string) { export async function deleteItem(ctx: AuthContext, id: string) {
if (!isAdmin(ctx.role)) throw new ApiError(403, "Only admins can delete items"); 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"); 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({ 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 },
},
}); });
} }

View File

@@ -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.21.1"; export const APP_VERSION = "0.22.0";

View File

@@ -7,6 +7,10 @@ const config: Config = {
"./components/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}",
"./src/**/*.{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: { theme: {
container: { container: {