From 93a7e9a7c2c29ddc3fd04ec1b721e4de7efd8f09 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 6 Sep 2026 20:46:30 +0200 Subject: [PATCH] feat(catalog): improve visual manager search and offer discovery --- .../catalog-items-table.tsx | 139 +++++++++++++----- .../catalog-manager-dialog.tsx | 117 +++++++++++++-- .../admin/catalog-manager/inline-editor.tsx | 42 +++--- .../catalog/components/catalog-search.tsx | 23 ++- .../catalog/domain/offer-discovery.test.ts | 109 ++++++++++++++ .../catalog/domain/offer-discovery.ts | 78 ++++++++++ src/messages/en.json | 23 ++- src/messages/it.json | 23 ++- src/messages/nl.json | 23 ++- 9 files changed, 505 insertions(+), 72 deletions(-) create mode 100644 src/features/catalog/domain/offer-discovery.test.ts create mode 100644 src/features/catalog/domain/offer-discovery.ts diff --git a/src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx b/src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx index 974b8107..4093bce4 100644 --- a/src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx +++ b/src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx @@ -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("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>( @@ -947,15 +972,16 @@ export function CatalogItemsTable({
Items ({items.length}) - {searchQuery && filteredItems.length !== items.length && ( - - ({filteredItems.length} shown) - - )} + {hasDiscoveryFilter && + filteredItems.length !== items.length && ( + + {discovery("shown", { count: filteredItems.length })} + + )} {selected.size > 0 && (

- {selected.size} selected + {discovery("selected", { count: selected.size })}

)}
@@ -1149,14 +1175,51 @@ export function CatalogItemsTable({ )} - {/* Search bar */} + {/* Offer discovery */} {items.length > 0 && ( - setSearchQuery("")} - /> +
+
+
+ updateSearch("")} + /> +
+ + {hasDiscoveryFilter && ( + + )} +
+

+ {discovery("results", { + count: filteredItems.length, + total: items.length, + })} + {canEdit && ` ${discovery("selectionHint")}`} +

+
)} @@ -1178,7 +1241,7 @@ export function CatalogItemsTable({ ) : filteredItems.length === 0 ? (

- No items match "{searchQuery}". + {discovery("noResults")}

) : viewMode === "grid" ? ( 0 && - selected.size === filteredItems.length + checked={allVisibleSelected} + indeterminate={ + someVisibleSelected && !allVisibleSelected } onCheckedChange={toggleAll} - aria-label="Select all" + aria-label={discovery("selectVisible")} /> )} @@ -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 ( ({ offerId: null, key: 0 }); const tActions = useTranslations("pages.admin.actions"); const t = useTranslations("pages.admin.catalog.workspace"); const mayLeave = useCallback( () => !state.editorDirty || window.confirm(tActions("leaveUnsaved")), [state.editorDirty, tActions], ); + const mayLeaveRef = useRef(mayLeave); + mayLeaveRef.current = mayLeave; const restoreSelection = useCallback( (selection: CatalogSelection) => { dispatch({ type: "RESTORE_SELECTION", ...selection }); @@ -196,6 +206,11 @@ function CatalogManagerInner({ if (target.closest?.("[role=listbox], [role=menu], [role=combobox]")) return; event.preventDefault(); + if (searchOpen) { + searchNavigation.cancel(); + setSearchOpen(false); + return; + } close(); }; const onBeforeUnload = (event: BeforeUnloadEvent) => { @@ -210,7 +225,7 @@ function CatalogManagerInner({ window.removeEventListener("keydown", onKey); window.removeEventListener("beforeunload", onBeforeUnload); }; - }, [close, state.editorDirty, embedded]); + }, [close, state.editorDirty, embedded, searchOpen, searchNavigation]); const loadRootPages = useCallback(async () => { const request = rootRequests.start(); @@ -253,10 +268,12 @@ function CatalogManagerInner({ }, [open, dispatch]); const handleTabClick = (id: number) => { + searchNavigation.cancel(); if (id === state.activeTabId) return; if (!mayLeave()) { return; } + setSearchTarget((previous) => ({ ...previous, offerId: null })); saveSelectionUrl({ rootId: id, pageId: null }); dispatch({ type: "SET_ACTIVE_TAB", id }); }; @@ -285,17 +302,21 @@ function CatalogManagerInner({ }; const handleSelectPage = (id: number) => { + searchNavigation.cancel(); if (id === state.selectedPageId) return; - if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) { + if (!mayLeave()) { return; } + setSearchTarget((previous) => ({ ...previous, offerId: null })); saveSelectionUrl({ rootId: state.activeTabId, pageId: id > 0 ? id : null }); dispatch({ type: "SELECT_PAGE", id: id > 0 ? id : null }); }; const handleEditTab = (id: number) => { + searchNavigation.cancel(); if (id === state.selectedPageId && id === state.activeTabId) return; if (!mayLeave()) return; + setSearchTarget((previous) => ({ ...previous, offerId: null })); saveSelectionUrl({ rootId: id, pageId: id }); if (state.activeTabId !== id) dispatch({ type: "SET_ACTIVE_TAB", id }); dispatch({ type: "SELECT_PAGE", id }); @@ -316,6 +337,49 @@ function CatalogManagerInner({ } }; + async function openSearchResult(result: CatalogSearchResult) { + const request = searchNavigation.start(); + try { + const response = await fetch( + `/api/admin/catalog/tree?ancestors=${result.pageId}${catQs}`, + { signal: request.signal }, + ); + if (!response.ok) throw Error("Navigation failed"); + const data = await response.json(); + if (!request.isCurrent() || !mayLeaveRef.current()) return; + const rootId = Number(data.ancestors?.[0]?.id ?? result.pageId); + dispatch({ type: "RESTORE_SELECTION", rootId, pageId: result.pageId }); + saveSelectionUrl({ rootId, pageId: result.pageId }); + setSearchTarget((previous) => ({ + offerId: result.kind === "offer" ? result.id : null, + key: previous.key + 1, + })); + setSearchOpen(false); + } catch { + if (request.isCurrent()) toast.error(t("searchFailed")); + } + } + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if ( + !(event.ctrlKey || event.metaKey) || + !event.shiftKey || + event.key.toLowerCase() !== "f" + ) + return; + const dialog = (event.target as HTMLElement).closest?.('[role="dialog"]'); + if ( + dialog && + dialog.getAttribute("aria-labelledby") !== + "visual-catalog-manager-title" + ) + return; + event.preventDefault(); + setSearchOpen(true); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, []); const heading = catalogType === "bc" ? "BC Visual Catalog Manager" @@ -334,19 +398,44 @@ function CatalogManagerInner({ {heading} - {!embedded && ( +
- )} + {!embedded && ( + + )} +
+ {searchOpen && ( +
+ +
+ )} @@ -387,6 +476,8 @@ function CatalogManagerInner({ {t("backToCategories")} diff --git a/src/components/admin/catalog-manager/inline-editor.tsx b/src/components/admin/catalog-manager/inline-editor.tsx index 8641894d..a35b1039 100644 --- a/src/components/admin/catalog-manager/inline-editor.tsx +++ b/src/components/admin/catalog-manager/inline-editor.tsx @@ -102,6 +102,7 @@ export interface PageUpdateData { } interface InlineEditorProps { + initialOfferId?: number | null; pageId: number | null; onSaved: (updated: PageUpdateData) => void; } @@ -115,7 +116,11 @@ export function InlineEditor(props: InlineEditorProps) { ); } -function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) { +function InlineEditorSession({ + pageId, + onSaved, + initialOfferId, +}: InlineEditorProps) { const pageRequests = useLatestRequest(); const itemRequests = useLatestRequest(); const saveRequests = useLatestRequest(); @@ -135,7 +140,9 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) { const [form, setForm] = useState({}); const [original, setOriginal] = useState({}); const [showTexts, setShowTexts] = useState(false); - const [activeTab, setActiveTab] = useState("settings"); + const [activeTab, setActiveTab] = useState( + initialOfferId ? "items" : "settings", + ); const [showPreview, setShowPreview] = useState(false); // Items/translate data (lazy loaded) @@ -247,7 +254,7 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) { loadPage(pageId); setItemsData(null); setItemsLoaded(false); - setActiveTab("settings"); + setActiveTab(initialOfferId ? "items" : "settings"); setShowPreview(false); } else { setPage(null); @@ -256,7 +263,7 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) { setItemsData(null); setItemsLoaded(false); } - }, [pageId, loadPage]); + }, [pageId, loadPage, initialOfferId]); // Lazy-load items (shop catalog only — BC items live on the detail page) useEffect(() => { @@ -360,14 +367,14 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) { return (
{/* Header */} -
+
-
+

{(form.caption as string) || page.caption}

@@ -379,13 +386,13 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) { > {form.pageLayout as string} - {isDirty && ( + {(isDirty || offersDirty) && ( - unsaved + {t("unsaved")} )}
@@ -397,9 +404,13 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
-
@@ -408,13 +419,7 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) { {isDirty && (
- - Unsaved changes — press{" "} - - Ctrl+S - {" "} - to save - + {t("saveHint")}