u
This commit is contained in:
@@ -14,6 +14,8 @@ import { Automat } from 'react-automat';
|
||||
import FlexSearch from 'flexsearch';
|
||||
import articleIndexAutomat from './articleIndexAutomat';
|
||||
import categoryAutomat from './categoryAutomat';
|
||||
import brandAutomat from './brandAutomat';
|
||||
import supplierAutomat from './supplierAutomat';
|
||||
import { parseFilter } from '../components/article/searchFormat';
|
||||
|
||||
// #region Types & Initial State
|
||||
@@ -22,6 +24,8 @@ import { parseFilter } from '../components/article/searchFormat';
|
||||
/**
|
||||
* @typedef {Object} ArticleSearchResult
|
||||
* @property {Record<string,object>} categoryFacets Facet counts per category id.
|
||||
* @property {Record<string,object>} brandFacets Facet counts per brand.
|
||||
* @property {Record<string,object>} supplierFacets Facet counts per supplier.
|
||||
* @property {number} totalCount Number of matching records.
|
||||
* @property {Array<object>} recordset Sliced matching article index records for the current page.
|
||||
* @property {number} offset Current 0-based offset.
|
||||
@@ -36,6 +40,8 @@ import { parseFilter } from '../components/article/searchFormat';
|
||||
* @property {ArticleSearchResult} result Latest search outcome.
|
||||
* @property {string} target Active search term.
|
||||
* @property {string} filter Active category filter string.
|
||||
* @property {string} brand Active brand filter string.
|
||||
* @property {string} supplier Active supplier filter string.
|
||||
* @property {number} offset Current 0-based offset.
|
||||
* @property {number|null} pageSize Current page size.
|
||||
* @property {boolean} loading Whether a search or index load is in flight.
|
||||
@@ -44,6 +50,8 @@ import { parseFilter } from '../components/article/searchFormat';
|
||||
/** @type {ArticleSearchResult} */
|
||||
const emptyResult = {
|
||||
categoryFacets: {},
|
||||
brandFacets: {},
|
||||
supplierFacets: {},
|
||||
totalCount: 0,
|
||||
recordset: [],
|
||||
offset: 0,
|
||||
@@ -58,6 +66,8 @@ const initialState = {
|
||||
result: emptyResult,
|
||||
target: '',
|
||||
filter: '',
|
||||
brand: '',
|
||||
supplier: '',
|
||||
offset: 0,
|
||||
pageSize: null,
|
||||
loading: false,
|
||||
@@ -565,6 +575,103 @@ function buildCategoryFacets(matchedArticles) {
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region buildBrandFacets
|
||||
// MARK: computes brand facet counts from matched articles
|
||||
/**
|
||||
* Computes facet counts for each brand represented in matched articles.
|
||||
*
|
||||
* @param {Array<object>} matchedArticles Articles matching text query.
|
||||
* @returns {Record<string, {id: string, name: string, count: number, brand: object, thumbHash: string|null}>} Brand facets.
|
||||
*/
|
||||
function buildBrandFacets(matchedArticles) {
|
||||
const facets = {};
|
||||
const allBrands = brandAutomat.state?.brands ?? [];
|
||||
const brandById = new Map();
|
||||
const brandByName = new Map();
|
||||
for (const b of allBrands) {
|
||||
if (b.id != null) brandById.set(String(b.id), b);
|
||||
if (b.name) brandByName.set(String(b.name).toLowerCase(), b);
|
||||
}
|
||||
|
||||
for (const article of matchedArticles) {
|
||||
const brandObj = article.brand;
|
||||
const bId = brandObj?.id ?? article.brandId;
|
||||
const bName = brandObj?.name ?? article.brandName;
|
||||
if (!bId && !bName) continue;
|
||||
|
||||
const brandKey = bId != null ? String(bId) : String(bName);
|
||||
const knownBrand = (bId != null ? brandById.get(String(bId)) : null) ||
|
||||
(bName ? brandByName.get(String(bName).toLowerCase()) : null);
|
||||
|
||||
const name = knownBrand?.name || bName || `Marke ${brandKey}`;
|
||||
const id = knownBrand?.id != null ? String(knownBrand.id) : brandKey;
|
||||
const thumbHash = knownBrand?.pics?.[0]?.thumbHash || brandObj?.thumbHash || article.brandThumbHash || null;
|
||||
|
||||
if (!facets[id]) {
|
||||
facets[id] = {
|
||||
id,
|
||||
name,
|
||||
thumbHash,
|
||||
brand: knownBrand || brandObj || { id, name, thumbHash },
|
||||
count: 0,
|
||||
};
|
||||
}
|
||||
facets[id].count += 1;
|
||||
}
|
||||
|
||||
return facets;
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region buildSupplierFacets
|
||||
// MARK: computes supplier facet counts from matched articles
|
||||
/**
|
||||
* Computes facet counts for each supplier represented in matched articles.
|
||||
*
|
||||
* @param {Array<object>} matchedArticles Articles matching text query.
|
||||
* @returns {Record<string, {id: string, name: string, count: number}>} Supplier facets.
|
||||
*/
|
||||
function buildSupplierFacets(matchedArticles) {
|
||||
const facets = {};
|
||||
const suppliers = supplierAutomat.state?.suppliers ?? [];
|
||||
const articlesBySupplier = supplierAutomat.state?.articlesBySupplier ?? {};
|
||||
|
||||
const supplierArticleSets = new Map();
|
||||
for (const s of suppliers) {
|
||||
const sId = String(s.id);
|
||||
const list = articlesBySupplier[sId] ?? [];
|
||||
const skuSet = new Set();
|
||||
for (const item of list) {
|
||||
if (item.article?.sku) skuSet.add(String(item.article.sku).toLowerCase());
|
||||
if (item.sku) skuSet.add(String(item.sku).toLowerCase());
|
||||
}
|
||||
supplierArticleSets.set(sId, skuSet);
|
||||
}
|
||||
|
||||
for (const article of matchedArticles) {
|
||||
const artSku = String(article.sku ?? '').toLowerCase();
|
||||
if (!artSku) continue;
|
||||
|
||||
for (const s of suppliers) {
|
||||
const sId = String(s.id);
|
||||
const skuSet = supplierArticleSets.get(sId);
|
||||
if (skuSet && skuSet.has(artSku)) {
|
||||
if (!facets[sId]) {
|
||||
facets[sId] = {
|
||||
id: sId,
|
||||
name: s.name,
|
||||
count: 0,
|
||||
};
|
||||
}
|
||||
facets[sId].count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return facets;
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Actions
|
||||
@@ -581,6 +688,8 @@ const actions = {
|
||||
const currentState = articleSearchAutomat.state;
|
||||
const target = params.target !== undefined ? String(params.target ?? '') : currentState.target;
|
||||
const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter;
|
||||
const brand = params.brand !== undefined ? String(params.brand ?? '') : (currentState.brand ?? '');
|
||||
const supplier = params.supplier !== undefined ? String(params.supplier ?? '') : (currentState.supplier ?? '');
|
||||
const pageSize = params.pageSize !== undefined ? params.pageSize : currentState.pageSize;
|
||||
const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset;
|
||||
|
||||
@@ -592,8 +701,10 @@ const actions = {
|
||||
? queryFlexSearch(query, articles)
|
||||
: articles;
|
||||
|
||||
// Build facets for all text matches (before category filtering)
|
||||
// Build facets for all text matches (before facet filtering)
|
||||
const categoryFacets = buildCategoryFacets(textMatches);
|
||||
const brandFacets = buildBrandFacets(textMatches);
|
||||
const supplierFacets = buildSupplierFacets(textMatches);
|
||||
|
||||
// Apply category filter if set (expanding to include all child categories)
|
||||
const selectedCategories = parseFilter(filter);
|
||||
@@ -606,11 +717,42 @@ const actions = {
|
||||
categoriesMap,
|
||||
categoryFacets,
|
||||
);
|
||||
filteredArticles = textMatches.filter((row) =>
|
||||
filteredArticles = filteredArticles.filter((row) =>
|
||||
matchesCategory(row, expandedCategoryIds),
|
||||
);
|
||||
}
|
||||
|
||||
// Apply brand filter if set
|
||||
const selectedBrands = parseFilter(brand);
|
||||
if (selectedBrands.length > 0) {
|
||||
const brandSet = new Set(selectedBrands.map((b) => String(b).trim().toLowerCase()));
|
||||
filteredArticles = filteredArticles.filter((row) => {
|
||||
const bId = row.brand?.id ?? row.brandId;
|
||||
const bName = row.brand?.name ?? row.brandName;
|
||||
if (bId != null && brandSet.has(String(bId).toLowerCase())) return true;
|
||||
if (bName && brandSet.has(String(bName).trim().toLowerCase())) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply supplier filter if set
|
||||
const selectedSuppliers = parseFilter(supplier);
|
||||
if (selectedSuppliers.length > 0) {
|
||||
const articlesBySupplier = supplierAutomat.state?.articlesBySupplier ?? {};
|
||||
const allowedSkus = new Set();
|
||||
for (const sId of selectedSuppliers) {
|
||||
const list = articlesBySupplier[String(sId)] ?? [];
|
||||
for (const item of list) {
|
||||
if (item.article?.sku) allowedSkus.add(String(item.article.sku).toLowerCase());
|
||||
if (item.sku) allowedSkus.add(String(item.sku).toLowerCase());
|
||||
}
|
||||
}
|
||||
filteredArticles = filteredArticles.filter((row) => {
|
||||
const rowSku = String(row.sku ?? '').toLowerCase();
|
||||
return allowedSkus.has(rowSku);
|
||||
});
|
||||
}
|
||||
|
||||
const sortedArticles = sortArticlesByRelevance(filteredArticles, query);
|
||||
|
||||
const totalCount = sortedArticles.length;
|
||||
@@ -623,6 +765,8 @@ const actions = {
|
||||
|
||||
const result = {
|
||||
categoryFacets,
|
||||
brandFacets,
|
||||
supplierFacets,
|
||||
totalCount,
|
||||
recordset,
|
||||
offset,
|
||||
@@ -636,6 +780,8 @@ const actions = {
|
||||
result,
|
||||
target,
|
||||
filter,
|
||||
brand,
|
||||
supplier,
|
||||
offset,
|
||||
pageSize: safePageSize,
|
||||
loading: articleIndexAutomat.state.status === 'loading',
|
||||
@@ -751,6 +897,16 @@ categoryAutomat.subscribe((catState) => {
|
||||
actions.runSearch();
|
||||
}
|
||||
});
|
||||
|
||||
// Update facets when brands are loaded or updated.
|
||||
brandAutomat.subscribe(() => {
|
||||
actions.runSearch();
|
||||
});
|
||||
|
||||
// Update facets when suppliers or supplier articles are loaded or updated.
|
||||
supplierAutomat.subscribe(() => {
|
||||
actions.runSearch();
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region Selectors & Exports
|
||||
|
||||
@@ -30,6 +30,8 @@ import authAutomat from './authAutomat';
|
||||
/** @type {SupplierState} */
|
||||
const initialState = {
|
||||
suppliers: [],
|
||||
articlesBySupplier: {},
|
||||
articlesLoading: false,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
@@ -62,6 +64,7 @@ function loadSuppliers() {
|
||||
try {
|
||||
const suppliers = await fetchSuppliers();
|
||||
supplierAutomat.setState({ suppliers, status: 'ready', error: null });
|
||||
void actions.loadAllSupplierArticles().catch(() => {});
|
||||
return suppliers;
|
||||
} catch (error) {
|
||||
supplierAutomat.setState({ status: 'failed', error });
|
||||
@@ -92,6 +95,7 @@ const actions = {
|
||||
try {
|
||||
const suppliers = await fetchSuppliers();
|
||||
supplierAutomat.setState({ suppliers, status: 'ready', error: null });
|
||||
void actions.loadAllSupplierArticles().catch(() => {});
|
||||
return suppliers;
|
||||
} catch (error) {
|
||||
supplierAutomat.setState({ error });
|
||||
@@ -99,6 +103,58 @@ const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads articles for a single supplier, caching them in state.articlesBySupplier.
|
||||
*
|
||||
* @param {string|number} supplierId
|
||||
* @returns {Promise<Array<object>>}
|
||||
*/
|
||||
async loadArticlesForSupplier(supplierId) {
|
||||
const sId = String(supplierId);
|
||||
if (supplierAutomat.state.articlesBySupplier?.[sId]) {
|
||||
return supplierAutomat.state.articlesBySupplier[sId];
|
||||
}
|
||||
const res = await supplierApi.getArticles(supplierId);
|
||||
const articles = Array.isArray(res?.articles) ? res.articles : [];
|
||||
supplierAutomat.setState({
|
||||
articlesBySupplier: {
|
||||
...(supplierAutomat.state.articlesBySupplier || {}),
|
||||
[sId]: articles,
|
||||
},
|
||||
});
|
||||
return articles;
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads articles for all suppliers in the supplier list, caching them in state.articlesBySupplier.
|
||||
*
|
||||
* @returns {Promise<Record<string, Array<object>>>}
|
||||
*/
|
||||
async loadAllSupplierArticles() {
|
||||
const suppliers = supplierAutomat.state.suppliers;
|
||||
if (!suppliers.length) return {};
|
||||
supplierAutomat.setState({ articlesLoading: true });
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
suppliers.map(async (s) => {
|
||||
const res = await supplierApi.getArticles(s.id);
|
||||
return { id: String(s.id), articles: Array.isArray(res?.articles) ? res.articles : [] };
|
||||
}),
|
||||
);
|
||||
const nextMap = { ...(supplierAutomat.state.articlesBySupplier || {}) };
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
nextMap[r.value.id] = r.value.articles;
|
||||
}
|
||||
}
|
||||
supplierAutomat.setState({ articlesBySupplier: nextMap, articlesLoading: false });
|
||||
return nextMap;
|
||||
} catch {
|
||||
supplierAutomat.setState({ articlesLoading: false });
|
||||
return supplierAutomat.state.articlesBySupplier || {};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a supplier and refreshes the cached list.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user