feat(catalog): improve visual manager search and offer discovery
This commit is contained in:
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 "{searchQuery}".
|
||||
{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}
|
||||
|
||||
Reference in new issue
Block a user