This commit is contained in:
seb
2026-10-18 04:32:38 +02:00
parent ea52753ef9
commit 3df8c5e9c7
4 changed files with 607 additions and 27 deletions

View File

@@ -14,6 +14,8 @@ import { Automat } from 'react-automat';
import FlexSearch from 'flexsearch'; import FlexSearch from 'flexsearch';
import articleIndexAutomat from './articleIndexAutomat'; import articleIndexAutomat from './articleIndexAutomat';
import categoryAutomat from './categoryAutomat'; import categoryAutomat from './categoryAutomat';
import brandAutomat from './brandAutomat';
import supplierAutomat from './supplierAutomat';
import { parseFilter } from '../components/article/searchFormat'; import { parseFilter } from '../components/article/searchFormat';
// #region Types & Initial State // #region Types & Initial State
@@ -22,6 +24,8 @@ import { parseFilter } from '../components/article/searchFormat';
/** /**
* @typedef {Object} ArticleSearchResult * @typedef {Object} ArticleSearchResult
* @property {Record<string,object>} categoryFacets Facet counts per category id. * @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 {number} totalCount Number of matching records.
* @property {Array<object>} recordset Sliced matching article index records for the current page. * @property {Array<object>} recordset Sliced matching article index records for the current page.
* @property {number} offset Current 0-based offset. * @property {number} offset Current 0-based offset.
@@ -36,6 +40,8 @@ import { parseFilter } from '../components/article/searchFormat';
* @property {ArticleSearchResult} result Latest search outcome. * @property {ArticleSearchResult} result Latest search outcome.
* @property {string} target Active search term. * @property {string} target Active search term.
* @property {string} filter Active category filter string. * @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} offset Current 0-based offset.
* @property {number|null} pageSize Current page size. * @property {number|null} pageSize Current page size.
* @property {boolean} loading Whether a search or index load is in flight. * @property {boolean} loading Whether a search or index load is in flight.
@@ -44,6 +50,8 @@ import { parseFilter } from '../components/article/searchFormat';
/** @type {ArticleSearchResult} */ /** @type {ArticleSearchResult} */
const emptyResult = { const emptyResult = {
categoryFacets: {}, categoryFacets: {},
brandFacets: {},
supplierFacets: {},
totalCount: 0, totalCount: 0,
recordset: [], recordset: [],
offset: 0, offset: 0,
@@ -58,6 +66,8 @@ const initialState = {
result: emptyResult, result: emptyResult,
target: '', target: '',
filter: '', filter: '',
brand: '',
supplier: '',
offset: 0, offset: 0,
pageSize: null, pageSize: null,
loading: false, loading: false,
@@ -565,6 +575,103 @@ function buildCategoryFacets(matchedArticles) {
} }
// #endregion // #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 // #endregion
// #region Actions // #region Actions
@@ -581,6 +688,8 @@ const actions = {
const currentState = articleSearchAutomat.state; const currentState = articleSearchAutomat.state;
const target = params.target !== undefined ? String(params.target ?? '') : currentState.target; const target = params.target !== undefined ? String(params.target ?? '') : currentState.target;
const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter; 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 pageSize = params.pageSize !== undefined ? params.pageSize : currentState.pageSize;
const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset; const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset;
@@ -592,8 +701,10 @@ const actions = {
? queryFlexSearch(query, articles) ? queryFlexSearch(query, articles)
: 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 categoryFacets = buildCategoryFacets(textMatches);
const brandFacets = buildBrandFacets(textMatches);
const supplierFacets = buildSupplierFacets(textMatches);
// Apply category filter if set (expanding to include all child categories) // Apply category filter if set (expanding to include all child categories)
const selectedCategories = parseFilter(filter); const selectedCategories = parseFilter(filter);
@@ -606,11 +717,42 @@ const actions = {
categoriesMap, categoriesMap,
categoryFacets, categoryFacets,
); );
filteredArticles = textMatches.filter((row) => filteredArticles = filteredArticles.filter((row) =>
matchesCategory(row, expandedCategoryIds), 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 sortedArticles = sortArticlesByRelevance(filteredArticles, query);
const totalCount = sortedArticles.length; const totalCount = sortedArticles.length;
@@ -623,6 +765,8 @@ const actions = {
const result = { const result = {
categoryFacets, categoryFacets,
brandFacets,
supplierFacets,
totalCount, totalCount,
recordset, recordset,
offset, offset,
@@ -636,6 +780,8 @@ const actions = {
result, result,
target, target,
filter, filter,
brand,
supplier,
offset, offset,
pageSize: safePageSize, pageSize: safePageSize,
loading: articleIndexAutomat.state.status === 'loading', loading: articleIndexAutomat.state.status === 'loading',
@@ -751,6 +897,16 @@ categoryAutomat.subscribe((catState) => {
actions.runSearch(); 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 // #endregion
// #region Selectors & Exports // #region Selectors & Exports

View File

@@ -30,6 +30,8 @@ import authAutomat from './authAutomat';
/** @type {SupplierState} */ /** @type {SupplierState} */
const initialState = { const initialState = {
suppliers: [], suppliers: [],
articlesBySupplier: {},
articlesLoading: false,
status: 'idle', status: 'idle',
error: null, error: null,
}; };
@@ -62,6 +64,7 @@ function loadSuppliers() {
try { try {
const suppliers = await fetchSuppliers(); const suppliers = await fetchSuppliers();
supplierAutomat.setState({ suppliers, status: 'ready', error: null }); supplierAutomat.setState({ suppliers, status: 'ready', error: null });
void actions.loadAllSupplierArticles().catch(() => {});
return suppliers; return suppliers;
} catch (error) { } catch (error) {
supplierAutomat.setState({ status: 'failed', error }); supplierAutomat.setState({ status: 'failed', error });
@@ -92,6 +95,7 @@ const actions = {
try { try {
const suppliers = await fetchSuppliers(); const suppliers = await fetchSuppliers();
supplierAutomat.setState({ suppliers, status: 'ready', error: null }); supplierAutomat.setState({ suppliers, status: 'ready', error: null });
void actions.loadAllSupplierArticles().catch(() => {});
return suppliers; return suppliers;
} catch (error) { } catch (error) {
supplierAutomat.setState({ 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. * Creates a supplier and refreshes the cached list.
* *

View File

@@ -4,14 +4,28 @@ export function queryFromParams(searchParams) {
return { return {
target: searchParams.get('target') ?? '', target: searchParams.get('target') ?? '',
filter: searchParams.get('filter') ?? '', filter: searchParams.get('filter') ?? '',
brand: searchParams.get('brand') ?? '',
supplier: searchParams.get('supplier') ?? '',
offset: Math.max(0, Number(searchParams.get('offset')) || 0), offset: Math.max(0, Number(searchParams.get('offset')) || 0),
}; };
} }
export function buildSearchUrl(target, filter = '', offset = 0) { export function buildSearchUrl(target, filter = '', offset = 0, options = {}) {
let brand = '';
let supplier = '';
if (typeof options === 'string') {
brand = options;
supplier = arguments[4] || '';
} else if (options && typeof options === 'object') {
brand = options.brand || '';
supplier = options.supplier || '';
}
const params = new URLSearchParams(); const params = new URLSearchParams();
if (target) params.set('target', target); if (target) params.set('target', target);
if (filter) params.set('filter', filter); if (filter) params.set('filter', filter);
if (brand) params.set('brand', brand);
if (supplier) params.set('supplier', supplier);
if (offset > 0) params.set('offset', String(offset)); if (offset > 0) params.set('offset', String(offset));
const qs = params.toString(); const qs = params.toString();
return qs ? `/artikel?${qs}` : '/artikel'; return qs ? `/artikel?${qs}` : '/artikel';
@@ -26,7 +40,13 @@ export function readArtikelQuery() {
} }
} }
export function writeArtikelQuery({ target, filter = '', offset = 0 }) { export function writeArtikelQuery({
target,
filter = '',
offset = 0,
brand = '',
supplier = '',
}) {
try { try {
if (!target || target.trim().length < 3) { if (!target || target.trim().length < 3) {
localStorage.removeItem(ARTIKEL_QUERY_KEY); localStorage.removeItem(ARTIKEL_QUERY_KEY);
@@ -34,7 +54,7 @@ export function writeArtikelQuery({ target, filter = '', offset = 0 }) {
} }
localStorage.setItem( localStorage.setItem(
ARTIKEL_QUERY_KEY, ARTIKEL_QUERY_KEY,
JSON.stringify({ target, filter, offset }), JSON.stringify({ target, filter, offset, brand, supplier }),
); );
} catch { } catch {
// ignore quota / private mode // ignore quota / private mode
@@ -53,5 +73,8 @@ export function clearArtikelQuery() {
export function artikelQueryPath() { export function artikelQueryPath() {
const query = readArtikelQuery(); const query = readArtikelQuery();
if (!query?.target || query.target.trim().length < 3) return '/artikel'; if (!query?.target || query.target.trim().length < 3) return '/artikel';
return buildSearchUrl(query.target, query.filter ?? '', query.offset ?? 0); return buildSearchUrl(query.target, query.filter ?? '', query.offset ?? 0, {
brand: query.brand ?? '',
supplier: query.supplier ?? '',
});
} }

View File

@@ -47,8 +47,10 @@ import {
navigationAutomat, navigationAutomat,
categoryAutomat, categoryAutomat,
companyAutomat, companyAutomat,
brandAutomat,
supplierAutomat,
} from '../automats'; } from '../automats';
import { BrandLabel } from '../components/brand'; import { BrandLabel, BrandThumb } from '../components/brand';
import { SCRIM_LABEL } from '../colors'; import { SCRIM_LABEL } from '../colors';
// #endregion // #endregion
@@ -266,8 +268,12 @@ function initialSearchState(searchParams) {
return { return {
input, input,
categoryFilterOpen: false, categoryFilterOpen: false,
brandFilterOpen: false,
supplierFilterOpen: false,
error: automatResult.error, error: automatResult.error,
categoryFacets: automatResult.categoryFacets, categoryFacets: automatResult.categoryFacets,
brandFacets: automatResult.brandFacets,
supplierFacets: automatResult.supplierFacets,
totalCount: automatResult.totalCount, totalCount: automatResult.totalCount,
recordset: automatResult.recordset, recordset: automatResult.recordset,
pageSize: automatResult.pageSize, pageSize: automatResult.pageSize,
@@ -313,7 +319,27 @@ class ArtikelSearchPage extends Component {
// #region Query Persistence // #region Query Persistence
toggleCategoryFilter = () => { toggleCategoryFilter = () => {
this.setState((prev) => ({ categoryFilterOpen: !prev.categoryFilterOpen })); this.setState((prev) => ({
categoryFilterOpen: !prev.categoryFilterOpen,
brandFilterOpen: false,
supplierFilterOpen: false,
}));
};
toggleBrandFilter = () => {
this.setState((prev) => ({
brandFilterOpen: !prev.brandFilterOpen,
categoryFilterOpen: false,
supplierFilterOpen: false,
}));
};
toggleSupplierFilter = () => {
this.setState((prev) => ({
supplierFilterOpen: !prev.supplierFilterOpen,
categoryFilterOpen: false,
brandFilterOpen: false,
}));
}; };
handleOpenArticle = (row) => { handleOpenArticle = (row) => {
@@ -327,6 +353,8 @@ class ArtikelSearchPage extends Component {
writeArtikelQuery({ writeArtikelQuery({
target, target,
filter: query.filter, filter: query.filter,
brand: query.brand,
supplier: query.supplier,
offset: query.offset, offset: query.offset,
}); });
}; };
@@ -339,16 +367,30 @@ class ArtikelSearchPage extends Component {
// Push search results into local component state. // Push search results into local component state.
this.unsubSearch = articleSearchAutomat.subscribe(this, (state) => state.result); this.unsubSearch = articleSearchAutomat.subscribe(this, (state) => state.result);
this.unsubBrand = brandAutomat.subscribe(this, () => ({}));
this.unsubSupplier = supplierAutomat.subscribe(this, () => ({}));
void brandAutomat.actions.load().catch(() => {});
void supplierAutomat.actions.load().catch(() => {});
void supplierAutomat.actions.loadAllSupplierArticles().catch(() => {});
// Initial search: kicks off index loading if idle and derives current items // Initial search: kicks off index loading if idle and derives current items
const { filter: urlFilter, offset } = queryFromParams(this.props.searchParams); const {
filter: urlFilter,
offset,
brand: urlBrand,
supplier: urlSupplier,
} = queryFromParams(this.props.searchParams);
const hasFilterInUrl = this.props.searchParams.has('filter'); const hasFilterInUrl = this.props.searchParams.has('filter');
const navCategory = navigationAutomat.state.lastCategoryId; const navCategory = navigationAutomat.state.lastCategoryId;
const filter = hasFilterInUrl ? urlFilter : (navCategory || urlFilter); const filter = hasFilterInUrl ? urlFilter : (navCategory || urlFilter);
const activeTarget = this.state.input; const activeTarget = this.state.input;
if (!hasFilterInUrl && navCategory) { if (!hasFilterInUrl && navCategory) {
const syncedUrl = buildSearchUrl(activeTarget, filter, offset); const syncedUrl = buildSearchUrl(activeTarget, filter, offset, {
brand: urlBrand,
supplier: urlSupplier,
});
setLastSearchTerm(activeTarget, syncedUrl); setLastSearchTerm(activeTarget, syncedUrl);
this.props.navigate(syncedUrl, { replace: true }); this.props.navigate(syncedUrl, { replace: true });
} else if (hasFilterInUrl) { } else if (hasFilterInUrl) {
@@ -357,10 +399,18 @@ class ArtikelSearchPage extends Component {
setLastCategoryId(primaryCatId); setLastCategoryId(primaryCatId);
} }
setLastSearchTerm(activeTarget, buildSearchUrl(activeTarget, filter, offset)); setLastSearchTerm(
activeTarget,
buildSearchUrl(activeTarget, filter, offset, {
brand: urlBrand,
supplier: urlSupplier,
}),
);
void articleSearchAutomat.actions.search({ void articleSearchAutomat.actions.search({
target: activeTarget, target: activeTarget,
filter, filter,
brand: urlBrand,
supplier: urlSupplier,
offset: 0, offset: 0,
}).catch(() => {}); }).catch(() => {});
} }
@@ -369,10 +419,20 @@ class ArtikelSearchPage extends Component {
document.removeEventListener('mouseup', preserveSearchFocus, true); document.removeEventListener('mouseup', preserveSearchFocus, true);
this.unsubSearch?.(); this.unsubSearch?.();
this.unsubSearch = null; this.unsubSearch = null;
this.unsubBrand?.();
this.unsubBrand = null;
this.unsubSupplier?.();
this.unsubSupplier = null;
this.persistQuery(); this.persistQuery();
const query = queryFromParams(this.props.searchParams); const query = queryFromParams(this.props.searchParams);
const target = this.state.input; const target = this.state.input;
setLastSearchTerm(target, buildSearchUrl(target, query.filter, query.offset)); setLastSearchTerm(
target,
buildSearchUrl(target, query.filter, query.offset, {
brand: query.brand,
supplier: query.supplier,
}),
);
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
@@ -389,6 +449,8 @@ class ArtikelSearchPage extends Component {
if ( if (
searchParams.get('filter') !== prev.get('filter') searchParams.get('filter') !== prev.get('filter')
|| searchParams.get('brand') !== prev.get('brand')
|| searchParams.get('supplier') !== prev.get('supplier')
|| searchParams.get('offset') !== prev.get('offset') || searchParams.get('offset') !== prev.get('offset')
) { ) {
this.fetchResults(); this.fetchResults();
@@ -397,13 +459,24 @@ class ArtikelSearchPage extends Component {
// #endregion // #endregion
// #region Search & Navigation Handlers // #region Search & Navigation Handlers
updateParams = (nextTarget, nextFilter, nextOffset = 0) => { updateParams = (
nextTarget,
nextFilter,
nextOffset = 0,
nextBrand = this.props.searchParams.get('brand') ?? '',
nextSupplier = this.props.searchParams.get('supplier') ?? '',
) => {
writeArtikelQuery({ writeArtikelQuery({
target: nextTarget, target: nextTarget,
filter: nextFilter, filter: nextFilter,
brand: nextBrand,
supplier: nextSupplier,
offset: nextOffset, offset: nextOffset,
}); });
const nextUrl = buildSearchUrl(nextTarget, nextFilter, nextOffset); const nextUrl = buildSearchUrl(nextTarget, nextFilter, nextOffset, {
brand: nextBrand,
supplier: nextSupplier,
});
setLastSearchTerm(nextTarget, nextUrl); setLastSearchTerm(nextTarget, nextUrl);
// Synchronize category with navigationAutomat and /kategorien/ // Synchronize category with navigationAutomat and /kategorien/
@@ -427,14 +500,16 @@ class ArtikelSearchPage extends Component {
handleInputChange = (event) => { handleInputChange = (event) => {
const value = event.target.value; const value = event.target.value;
this.setState({ input: value }); this.setState({ input: value });
const { filter } = queryFromParams(this.props.searchParams); const { filter, brand, supplier } = queryFromParams(this.props.searchParams);
const currentUrl = buildSearchUrl(value, filter, 0); const currentUrl = buildSearchUrl(value, filter, 0, { brand, supplier });
setLastSearchTerm(value, currentUrl); setLastSearchTerm(value, currentUrl);
writeArtikelQuery({ target: value, filter, offset: 0 }); writeArtikelQuery({ target: value, filter, brand, supplier, offset: 0 });
this.props.navigate(currentUrl, { replace: true }); this.props.navigate(currentUrl, { replace: true });
void articleSearchAutomat.actions.search({ void articleSearchAutomat.actions.search({
target: value, target: value,
filter, filter,
brand,
supplier,
offset: 0, offset: 0,
}).catch(() => {}); }).catch(() => {});
}; };
@@ -444,13 +519,15 @@ class ArtikelSearchPage extends Component {
}; };
fetchResults = () => { fetchResults = () => {
const { target, filter, offset } = queryFromParams(this.props.searchParams); const { target, filter, brand, supplier, offset } = queryFromParams(this.props.searchParams);
writeArtikelQuery({ target, filter, offset }); writeArtikelQuery({ target, filter, brand, supplier, offset });
void articleSearchAutomat.actions.search({ void articleSearchAutomat.actions.search({
target: target.trim(), target: target.trim(),
filter, filter,
brand,
supplier,
offset, offset,
}).catch(() => {}); }).catch(() => {});
}; };
@@ -461,13 +538,22 @@ class ArtikelSearchPage extends Component {
const { const {
input, input,
categoryFilterOpen, categoryFilterOpen,
brandFilterOpen,
supplierFilterOpen,
error, error,
totalCount, totalCount,
categoryFacets, categoryFacets,
brandFacets,
supplierFacets,
recordset, recordset,
} = this.state; } = this.state;
const filterParam = this.props.searchParams.get('filter') ?? ''; const filterParam = this.props.searchParams.get('filter') ?? '';
const brandParam = this.props.searchParams.get('brand') ?? '';
const supplierParam = this.props.searchParams.get('supplier') ?? '';
const categories = parseFilter(filterParam); const categories = parseFilter(filterParam);
const selectedBrands = parseFilter(brandParam);
const selectedSuppliers = parseFilter(supplierParam);
const rows = recordset ?? []; const rows = recordset ?? [];
const categoriesMap = categoryAutomat.state?.categories; const categoriesMap = categoryAutomat.state?.categories;
@@ -503,6 +589,20 @@ class ArtikelSearchPage extends Component {
}) })
: []; : [];
const brandFacetEntries = brandFacets
? Object.values(brandFacets).sort((a, b) => {
if (b.count !== a.count) return b.count - a.count;
return String(a.name).localeCompare(String(b.name), 'de');
})
: [];
const supplierFacetEntries = supplierFacets
? Object.values(supplierFacets).sort((a, b) => {
if (b.count !== a.count) return b.count - a.count;
return String(a.name).localeCompare(String(b.name), 'de');
})
: [];
// Per-render values the static `renderItem` row reads through the context. // Per-render values the static `renderItem` row reads through the context.
resultRowsContext.categoryFacets = categoryFacets ?? {}; resultRowsContext.categoryFacets = categoryFacets ?? {};
resultRowsContext.onOpenArticle = this.handleOpenArticle; resultRowsContext.onOpenArticle = this.handleOpenArticle;
@@ -517,6 +617,7 @@ class ArtikelSearchPage extends Component {
gap: 1.5, gap: 1.5,
alignItems: 'center', alignItems: 'center',
minHeight: 40, minHeight: 40,
flexWrap: 'wrap',
}} }}
> >
<TextField <TextField
@@ -557,7 +658,7 @@ class ArtikelSearchPage extends Component {
<Button <Button
variant={categoryFilterOpen ? 'contained' : 'outlined'} variant={categoryFilterOpen ? 'contained' : 'outlined'}
color="inherit" color="inherit"
disabled={facetEntries.length === 0} disabled={facetEntries.length === 0 && categories.length === 0}
onClick={this.toggleCategoryFilter} onClick={this.toggleCategoryFilter}
startIcon={<FilterListIcon />} startIcon={<FilterListIcon />}
endIcon={categoryFilterOpen ? <ExpandLessIcon /> : <ExpandMoreIcon />} endIcon={categoryFilterOpen ? <ExpandLessIcon /> : <ExpandMoreIcon />}
@@ -565,6 +666,29 @@ class ArtikelSearchPage extends Component {
> >
Kategorien Kategorien
</Button> </Button>
<Button
variant={brandFilterOpen ? 'contained' : 'outlined'}
color="inherit"
disabled={brandFacetEntries.length === 0 && selectedBrands.length === 0}
onClick={this.toggleBrandFilter}
startIcon={<FilterListIcon />}
endIcon={brandFilterOpen ? <ExpandLessIcon /> : <ExpandMoreIcon />}
sx={{ height: 40, whiteSpace: 'nowrap', flexShrink: 0 }}
>
Marken
</Button>
<Button
variant={supplierFilterOpen ? 'contained' : 'outlined'}
color="inherit"
disabled={supplierFacetEntries.length === 0 && selectedSuppliers.length === 0}
onClick={this.toggleSupplierFilter}
startIcon={<FilterListIcon />}
endIcon={supplierFilterOpen ? <ExpandLessIcon /> : <ExpandMoreIcon />}
sx={{ height: 40, whiteSpace: 'nowrap', flexShrink: 0 }}
>
Lieferanten
</Button>
{categories.map((catId) => { {categories.map((catId) => {
const label = getCategoryBreadcrumbLabel( const label = getCategoryBreadcrumbLabel(
catId, catId,
@@ -574,13 +698,71 @@ class ArtikelSearchPage extends Component {
); );
return ( return (
<Chip <Chip
key={catId} key={`cat-${catId}`}
label={label} label={label}
color="primary" color="primary"
variant="outlined" variant="outlined"
onDelete={() => { onDelete={() => {
const next = categories.filter((id) => id !== catId); const next = categories.filter((id) => id !== catId);
this.updateParams(input, buildFilter(next), 0); this.updateParams(
input,
buildFilter(next),
0,
buildFilter(selectedBrands),
buildFilter(selectedSuppliers),
);
}}
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
/>
);
})}
{selectedBrands.map((brandId) => {
const label =
brandFacets?.[brandId]?.name ||
brandAutomat.state.brands?.find((b) => String(b.id) === String(brandId) || b.name === brandId)?.name ||
brandId;
return (
<Chip
key={`brand-${brandId}`}
label={label}
color="primary"
variant="outlined"
onDelete={() => {
const next = selectedBrands.filter((id) => id !== brandId);
this.updateParams(
input,
buildFilter(categories),
0,
buildFilter(next),
buildFilter(selectedSuppliers),
);
}}
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
/>
);
})}
{selectedSuppliers.map((supplierId) => {
const label =
supplierFacets?.[supplierId]?.name ||
supplierAutomat.state.suppliers?.find((s) => String(s.id) === String(supplierId))?.name ||
`Lieferant #${supplierId}`;
return (
<Chip
key={`sup-${supplierId}`}
label={label}
color="primary"
variant="outlined"
onDelete={() => {
const next = selectedSuppliers.filter((id) => id !== supplierId);
this.updateParams(
input,
buildFilter(categories),
0,
buildFilter(selectedBrands),
buildFilter(next),
);
}} }}
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }} sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
/> />
@@ -602,8 +784,6 @@ class ArtikelSearchPage extends Component {
sx={{ sx={{
width: 240, width: 240,
flexShrink: 0, flexShrink: 0,
// No intrinsic height: the results tile drives the row height
// and the absolutely-filled panel matches it exactly.
position: 'relative', position: 'relative',
}} }}
> >
@@ -616,8 +796,6 @@ class ArtikelSearchPage extends Component {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
overflow: 'hidden', overflow: 'hidden',
// Match PanelTile (PANEL_TILE_BORDER_RADIUS: borderRadius 4 →
// theme.shape.borderRadius × 4 = 16px) so both columns line up.
borderRadius: 4, borderRadius: 4,
}} }}
> >
@@ -654,7 +832,13 @@ class ArtikelSearchPage extends Component {
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => { onClick={() => {
const next = selected ? [] : [id]; const next = selected ? [] : [id];
this.updateParams(input, buildFilter(next), 0); this.updateParams(
input,
buildFilter(next),
0,
buildFilter(selectedBrands),
buildFilter(selectedSuppliers),
);
}} }}
sx={{ pl: 2 + depth * 1.5 }} sx={{ pl: 2 + depth * 1.5 }}
> >
@@ -673,6 +857,167 @@ class ArtikelSearchPage extends Component {
</Box> </Box>
)} )}
{brandFilterOpen && brandFacetEntries.length > 0 && (
<Box
sx={{
width: 240,
flexShrink: 0,
position: 'relative',
}}
>
<Paper
variant="outlined"
sx={{
position: 'absolute',
inset: 0,
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
borderRadius: 4,
}}
>
<Box
sx={{
px: 2,
py: 1.5,
bgcolor: 'action.hover',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Marken
</Typography>
<IconButton
size="small"
onClick={this.toggleBrandFilter}
title="Marken einklappen"
aria-label="Marken einklappen"
>
<ChevronLeftIcon fontSize="small" />
</IconButton>
</Box>
<List dense disablePadding sx={{ flexGrow: 1, overflowY: 'auto' }}>
{brandFacetEntries.map(({ id, count, name, brand }) => {
const selected = selectedBrands.includes(String(id));
return (
<ListItemButton
key={id}
selected={selected}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
const next = selected ? [] : [String(id)];
this.updateParams(
input,
buildFilter(categories),
0,
buildFilter(next),
buildFilter(selectedSuppliers),
);
}}
sx={{ pl: 2 }}
>
{brand ? (
<Box sx={{ mr: 1, display: 'inline-flex', alignItems: 'center' }}>
<BrandThumb brand={brand} size={18} />
</Box>
) : null}
<ListItemText
primary={`${name} (${count})`}
slotProps={{ primary: { variant: 'body2', noWrap: true } }}
/>
{selected && (
<ClearIcon fontSize="small" color="action" sx={{ ml: 1, flexShrink: 0 }} />
)}
</ListItemButton>
);
})}
</List>
</Paper>
</Box>
)}
{supplierFilterOpen && supplierFacetEntries.length > 0 && (
<Box
sx={{
width: 240,
flexShrink: 0,
position: 'relative',
}}
>
<Paper
variant="outlined"
sx={{
position: 'absolute',
inset: 0,
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
borderRadius: 4,
}}
>
<Box
sx={{
px: 2,
py: 1.5,
bgcolor: 'action.hover',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Lieferanten
</Typography>
<IconButton
size="small"
onClick={this.toggleSupplierFilter}
title="Lieferanten einklappen"
aria-label="Lieferanten einklappen"
>
<ChevronLeftIcon fontSize="small" />
</IconButton>
</Box>
<List dense disablePadding sx={{ flexGrow: 1, overflowY: 'auto' }}>
{supplierFacetEntries.map(({ id, count, name }) => {
const selected = selectedSuppliers.includes(String(id));
return (
<ListItemButton
key={id}
selected={selected}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
const next = selected ? [] : [String(id)];
this.updateParams(
input,
buildFilter(categories),
0,
buildFilter(selectedBrands),
buildFilter(next),
);
}}
sx={{ pl: 2 }}
>
<ListItemText
primary={`${name} (${count})`}
slotProps={{ primary: { variant: 'body2', noWrap: true } }}
/>
{selected && (
<ClearIcon fontSize="small" color="action" sx={{ ml: 1, flexShrink: 0 }} />
)}
</ListItemButton>
);
})}
</List>
</Paper>
</Box>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}> <Box sx={{ flexGrow: 1, minWidth: 0 }}>
{!error && input.trim().length > 0 && totalCount === 0 && ( {!error && input.trim().length > 0 && totalCount === 0 && (
<Typography color="text.secondary">Keine Artikel gefunden.</Typography> <Typography color="text.secondary">Keine Artikel gefunden.</Typography>