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