feat(08-02): add search/filter toolbar to gear tab and upgrade planning filter

- Sticky search/filter toolbar with text input and CategoryFilterDropdown
- useMemo-based filtering by name (search) and categoryId (dropdown)
- "Showing X of Y items" count when filters active
- Flat grid (no category headers) when any filter is active
- "No items match your search" empty state for filtered results
- Replace PlanningView native select with CategoryFilterDropdown
This commit is contained in:
2026-03-16 14:09:51 +01:00
parent ca1c2a2e57
commit 5f89acd503

View File

@@ -1,6 +1,7 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react"; import { useMemo, useState } from "react";
import { z } from "zod"; import { z } from "zod";
import { CategoryFilterDropdown } from "../../components/CategoryFilterDropdown";
import { CategoryHeader } from "../../components/CategoryHeader"; import { CategoryHeader } from "../../components/CategoryHeader";
import { CreateThreadModal } from "../../components/CreateThreadModal"; import { CreateThreadModal } from "../../components/CreateThreadModal";
import { ItemCard } from "../../components/ItemCard"; import { ItemCard } from "../../components/ItemCard";
@@ -51,8 +52,26 @@ function CollectionPage() {
function CollectionView() { function CollectionView() {
const { data: items, isLoading: itemsLoading } = useItems(); const { data: items, isLoading: itemsLoading } = useItems();
const { data: totals } = useTotals(); const { data: totals } = useTotals();
const { data: categories } = useCategories();
const openAddPanel = useUIStore((s) => s.openAddPanel); const openAddPanel = useUIStore((s) => s.openAddPanel);
const [searchText, setSearchText] = useState("");
const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
const filteredItems = useMemo(() => {
if (!items) return [];
return items.filter((item) => {
const matchesSearch =
searchText === "" ||
item.name.toLowerCase().includes(searchText.toLowerCase());
const matchesCategory =
categoryFilter === null || item.categoryId === categoryFilter;
return matchesSearch && matchesCategory;
});
}, [items, searchText, categoryFilter]);
const hasActiveFilters = searchText !== "" || categoryFilter !== null;
if (itemsLoading) { if (itemsLoading) {
return ( return (
<div className="animate-pulse space-y-6"> <div className="animate-pulse space-y-6">
@@ -110,25 +129,6 @@ function CollectionView() {
); );
} }
// Group items by categoryId
const groupedItems = new Map<
number,
{ items: typeof items; categoryName: string; categoryIcon: string }
>();
for (const item of items) {
const group = groupedItems.get(item.categoryId);
if (group) {
group.items.push(item);
} else {
groupedItems.set(item.categoryId, {
items: [item],
categoryName: item.categoryName,
categoryIcon: item.categoryIcon,
});
}
}
// Build category totals lookup // Build category totals lookup
const categoryTotalsMap = new Map< const categoryTotalsMap = new Map<
number, number,
@@ -144,42 +144,138 @@ function CollectionView() {
} }
} }
// Group filtered items by categoryId (used when no active filters)
const groupedItems = new Map<
number,
{
items: typeof filteredItems;
categoryName: string;
categoryIcon: string;
}
>();
for (const item of filteredItems) {
const group = groupedItems.get(item.categoryId);
if (group) {
group.items.push(item);
} else {
groupedItems.set(item.categoryId, {
items: [item],
categoryName: item.categoryName,
categoryIcon: item.categoryIcon,
});
}
}
return ( return (
<> <>
{Array.from(groupedItems.entries()).map( {/* Search/filter toolbar */}
([ <div className="sticky top-0 z-10 bg-white/95 backdrop-blur-sm border-b border-gray-100 -mx-4 px-4 py-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 mb-6">
categoryId, <div className="flex gap-3 items-center">
{ items: categoryItems, categoryName, categoryIcon }, <div className="relative flex-1">
]) => { <input
const catTotals = categoryTotalsMap.get(categoryId); type="text"
return ( placeholder="Search items..."
<div key={categoryId} className="mb-8"> value={searchText}
<CategoryHeader onChange={(e) => setSearchText(e.target.value)}
categoryId={categoryId} className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
name={categoryName} />
icon={categoryIcon} {searchText && (
totalWeight={catTotals?.totalWeight ?? 0} <button
totalCost={catTotals?.totalCost ?? 0} type="button"
itemCount={catTotals?.itemCount ?? categoryItems.length} onClick={() => setSearchText("")}
/> className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> >
{categoryItems.map((item) => ( <svg
<ItemCard className="w-4 h-4"
key={item.id} fill="none"
id={item.id} stroke="currentColor"
name={item.name} viewBox="0 0 24 24"
weightGrams={item.weightGrams} >
priceCents={item.priceCents} <path
categoryName={categoryName} strokeLinecap="round"
categoryIcon={categoryIcon} strokeLinejoin="round"
imageFilename={item.imageFilename} strokeWidth={2}
productUrl={item.productUrl} d="M6 18L18 6M6 6l12 12"
/> />
))} </svg>
</button>
)}
</div>
<CategoryFilterDropdown
value={categoryFilter}
onChange={setCategoryFilter}
categories={categories ?? []}
/>
</div>
{hasActiveFilters && (
<p className="text-xs text-gray-500 mt-2">
Showing {filteredItems.length} of {items.length} items
</p>
)}
</div>
{/* Filtered results */}
{hasActiveFilters ? (
filteredItems.length === 0 ? (
<div className="py-12 text-center">
<p className="text-sm text-gray-500">
No items match your search
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredItems.map((item) => (
<ItemCard
key={item.id}
id={item.id}
name={item.name}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
categoryName={item.categoryName}
categoryIcon={item.categoryIcon}
imageFilename={item.imageFilename}
productUrl={item.productUrl}
/>
))}
</div>
)
) : (
Array.from(groupedItems.entries()).map(
([
categoryId,
{ items: categoryItems, categoryName, categoryIcon },
]) => {
const catTotals = categoryTotalsMap.get(categoryId);
return (
<div key={categoryId} className="mb-8">
<CategoryHeader
categoryId={categoryId}
name={categoryName}
icon={categoryIcon}
totalWeight={catTotals?.totalWeight ?? 0}
totalCost={catTotals?.totalCost ?? 0}
itemCount={catTotals?.itemCount ?? categoryItems.length}
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categoryItems.map((item) => (
<ItemCard
key={item.id}
id={item.id}
name={item.name}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
categoryName={categoryName}
categoryIcon={categoryIcon}
imageFilename={item.imageFilename}
productUrl={item.productUrl}
/>
))}
</div>
</div> </div>
</div> );
); },
}, )
)} )}
</> </>
); );
@@ -274,20 +370,11 @@ function PlanningView() {
</div> </div>
{/* Category filter */} {/* Category filter */}
<select <CategoryFilterDropdown
value={categoryFilter ?? ""} value={categoryFilter}
onChange={(e) => onChange={setCategoryFilter}
setCategoryFilter(e.target.value ? Number(e.target.value) : null) categories={categories ?? []}
} />
className="px-3 py-1.5 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
>
<option value="">All categories</option>
{categories?.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div> </div>
{/* Content: empty state or thread grid */} {/* Content: empty state or thread grid */}