feat(catalog): improve visual manager search and offer discovery
CI / check (push) Successful in 1m14s
CI / deploy (push) Successful in 1m29s

This commit is contained in:
Simo committed 2026-09-06 20:58:53 +02:00
1 parent 08321df2aa
commit 93a7e9a7c2
9 files changed
+505 -72

No files matched your search

@@ -82,6 +82,11 @@ import {
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BulkOfferEditor } from "@/features/catalog/components/bulk-offer-editor";
import {
discoverOffers,
OFFER_FILTERS,
type OfferFilter,
} from "@/features/catalog/domain/offer-discovery";
import { useServerAction } from "@/hooks/use-server-action";
import { getSuggestions, recordMove } from "@/lib/move-suggestions";
import { cn } from "@/lib/utils";
@@ -146,6 +151,7 @@ function SortableRow({
}
interface CatalogItemsTableProps {
initialSearch?: string;
onDirtyChange?: (dirty: boolean) => void;
onRefresh?: () => void;
items: CatalogItemData[];
@@ -158,6 +164,7 @@ interface CatalogItemsTableProps {
}
export function CatalogItemsTable({
initialSearch = "",
onDirtyChange,
onRefresh,
items,
@@ -170,6 +177,7 @@ export function CatalogItemsTable({
}: CatalogItemsTableProps) {
const router = useRouter();
const bulk = useTranslations("pages.admin.catalog.bulk");
const discovery = useTranslations("pages.admin.catalog.discovery");
const [bulkRefreshFrom, setBulkRefreshFrom] = useState<
CatalogItemData[] | null
>(null);
@@ -186,20 +194,14 @@ export function CatalogItemsTable({
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
// ── Search / Filter ─────────────────────────────────────────────
const [searchQuery, setSearchQuery] = useState("");
const filteredItems = useMemo(() => {
if (!searchQuery.trim()) return items;
const q = searchQuery.toLowerCase();
return items.filter(
(item) =>
item.catalogName.toLowerCase().includes(q) ||
item.baseItemName.toLowerCase().includes(q) ||
item.baseName.toLowerCase().includes(q) ||
item.itemIds.includes(q) ||
String(item.id).includes(q),
);
}, [items, searchQuery]);
const [searchQuery, setSearchQuery] = useState(initialSearch);
const [offerFilter, setOfferFilter] = useState<OfferFilter>("all");
const { items: filteredItems, counts: filterCounts } = useMemo(
() => discoverOffers(items, searchQuery, offerFilter),
[items, searchQuery, offerFilter],
);
const hasDiscoveryFilter =
Boolean(searchQuery.trim()) || offerFilter !== "all";
// ── Move items dialog ───────────────────────────────────────────
const [moveOpen, setMoveOpen] = useState(false);
@@ -242,13 +244,36 @@ export function CatalogItemsTable({
});
}, []);
const allVisibleSelected =
filteredItems.length > 0 &&
filteredItems.every((item) => selected.has(item.id));
const someVisibleSelected = filteredItems.some((item) =>
selected.has(item.id),
);
const toggleAll = useCallback(() => {
setSelected((prev) =>
prev.size === filteredItems.length
? new Set()
: new Set(filteredItems.map((i) => i.id)),
);
setSelected((previous) => {
const next = new Set(previous);
const allSelected = filteredItems.every((item) => previous.has(item.id));
for (const item of filteredItems) {
if (allSelected) next.delete(item.id);
else next.add(item.id);
}
return next;
});
}, [filteredItems]);
function updateSearch(query: string) {
setSearchQuery(query);
setSelected(new Set());
}
function updateOfferFilter(value: string) {
setOfferFilter(value as OfferFilter);
setSelected(new Set());
}
function resetDiscovery() {
setSearchQuery("");
setOfferFilter("all");
setSelected(new Set());
}
// ── Inline edits ─────────────────────────────────────────────────
const [inlineEdits, setInlineEdits] = useState<Record<number, InlineEdit>>(
@@ -947,15 +972,16 @@ export function CatalogItemsTable({
<div className="min-w-0">
<CardTitle>
Items ({items.length})
{searchQuery && filteredItems.length !== items.length && (
<span className="text-muted-foreground font-normal text-sm ml-2">
({filteredItems.length} shown)
</span>
)}
{hasDiscoveryFilter &&
filteredItems.length !== items.length && (
<span className="text-muted-foreground font-normal text-sm ml-2">
{discovery("shown", { count: filteredItems.length })}
</span>
)}
</CardTitle>
{selected.size > 0 && (
<p className="text-sm text-muted-foreground mt-1">
{selected.size} selected
{discovery("selected", { count: selected.size })}
</p>
)}
</div>
@@ -1149,14 +1175,51 @@ export function CatalogItemsTable({
</div>
)}
{/* Search bar */}
{/* Offer discovery */}
{items.length > 0 && (
<SearchInput
placeholder="Filter by name, classname, item ID..."
value={searchQuery}
onChange={setSearchQuery}
onClear={() => setSearchQuery("")}
/>
<div className="space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<SearchInput
placeholder={discovery("searchPlaceholder")}
value={searchQuery}
onChange={updateSearch}
onClear={() => updateSearch("")}
/>
</div>
<Select value={offerFilter} onValueChange={updateOfferFilter}>
<SelectTrigger
aria-label={discovery("filterLabel")}
className="w-full sm:w-52"
>
<SelectValue>
{discovery(`filters.${offerFilter}`)} (
{filterCounts[offerFilter]})
</SelectValue>
</SelectTrigger>
<SelectContent>
{OFFER_FILTERS.map((filter) => (
<SelectItem key={filter} value={filter}>
{discovery(`filters.${filter}`)} ({filterCounts[filter]}
)
</SelectItem>
))}
</SelectContent>
</Select>
{hasDiscoveryFilter && (
<Button variant="ghost" size="sm" onClick={resetDiscovery}>
{discovery("reset")}
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
{discovery("results", {
count: filteredItems.length,
total: items.length,
})}
{canEdit && ` ${discovery("selectionHint")}`}
</p>
</div>
)}
</CardHeader>
<CardContent>
@@ -1178,7 +1241,7 @@ export function CatalogItemsTable({
</div>
) : filteredItems.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No items match &quot;{searchQuery}&quot;.
{discovery("noResults")}
</p>
) : viewMode === "grid" ? (
<ItemsShopPreview
@@ -1204,12 +1267,12 @@ export function CatalogItemsTable({
{canEdit && (
<TableHead className="w-[40px] px-2">
<Checkbox
checked={
filteredItems.length > 0 &&
selected.size === filteredItems.length
checked={allVisibleSelected}
indeterminate={
someVisibleSelected && !allVisibleSelected
}
onCheckedChange={toggleAll}
aria-label="Select all"
aria-label={discovery("selectVisible")}
/>
</TableHead>
)}
@@ -1237,7 +1300,7 @@ export function CatalogItemsTable({
const modified = isItemModified(item);
const isSaving = savingItemId === item.id;
const furniInfo = getFurniInfo(item);
const sortableDisabled = !canEdit || !!searchQuery;
const sortableDisabled = !canEdit || hasDiscoveryFilter;
return (
<SortableRow
key={item.id}