Compare commits
5 Commits
4d653304cd
...
26bee398a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26bee398a6 | ||
|
|
358d34ecab | ||
|
|
8c5419c89e | ||
|
|
5b4cf1683a | ||
|
|
8010436157 |
@@ -98,6 +98,7 @@ function scheduleRebuild() {
|
|||||||
function toIndexRecord(article, categoryId) {
|
function toIndexRecord(article, categoryId) {
|
||||||
const eans = article.eans ?? article.eanCodes ?? article.ean ?? null;
|
const eans = article.eans ?? article.eanCodes ?? article.ean ?? null;
|
||||||
return {
|
return {
|
||||||
|
...article,
|
||||||
id: article.id,
|
id: article.id,
|
||||||
sku: article.sku,
|
sku: article.sku,
|
||||||
name: article.name,
|
name: article.name,
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* @file articleSearchAutomat.js
|
* @file articleSearchAutomat.js
|
||||||
* @description In-memory article search state for the `/artikel` overview and
|
* @description In-memory article search and pagination state for the `/artikel`
|
||||||
* search pages, querying against the derived `articleIndexAutomat`.
|
* overview and search pages, querying against the derived `articleIndexAutomat`.
|
||||||
*
|
*
|
||||||
* Upstream Triggers:
|
* Upstream Triggers:
|
||||||
* - UI / Search input: Components call `actions.search(query)` or `actions.reset()`.
|
* - UI / Search input / FillSpace: Components call `actions.search({ target, filter, offset, pageSize })`.
|
||||||
* - Reads data from `articleIndexAutomat`: Ensures the derived index is available
|
* - Reads data from `articleIndexAutomat`: Derives and slices matching records
|
||||||
* before filtering. The index is built from the loaded category views, so a
|
* synchronously, updating continuously as category views load during launch.
|
||||||
* search never issues a request of its own.
|
* - `categoryAutomat`: Provides category hierarchy metadata for faceted counts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Automat } from 'react-automat';
|
import { Automat } from 'react-automat';
|
||||||
import articleIndexAutomat from './articleIndexAutomat';
|
import articleIndexAutomat from './articleIndexAutomat';
|
||||||
|
import categoryAutomat from './categoryAutomat';
|
||||||
|
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 {number} totalCount Number of matching records.
|
* @property {number} totalCount Number of matching records.
|
||||||
* @property {Array<object>} recordset Matching article index records.
|
* @property {Array<object>} recordset Sliced matching article index records for the current page.
|
||||||
|
* @property {number} offset Current 0-based offset.
|
||||||
|
* @property {number|null} pageSize Current page size.
|
||||||
|
* @property {number} pageCount Total number of pages.
|
||||||
|
* @property {number} currentPage Current 1-based page number.
|
||||||
* @property {Error|null} error Search failure, or `null`.
|
* @property {Error|null} error Search failure, or `null`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -25,7 +31,10 @@ import articleIndexAutomat from './articleIndexAutomat';
|
|||||||
* @typedef {Object} ArticleSearchState
|
* @typedef {Object} ArticleSearchState
|
||||||
* @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 {boolean} loading Whether a search is in flight.
|
* @property {string} filter Active category 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @type {ArticleSearchResult} */
|
/** @type {ArticleSearchResult} */
|
||||||
@@ -33,6 +42,10 @@ const emptyResult = {
|
|||||||
categoryFacets: {},
|
categoryFacets: {},
|
||||||
totalCount: 0,
|
totalCount: 0,
|
||||||
recordset: [],
|
recordset: [],
|
||||||
|
offset: 0,
|
||||||
|
pageSize: null,
|
||||||
|
pageCount: 1,
|
||||||
|
currentPage: 1,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,73 +53,487 @@ const emptyResult = {
|
|||||||
const initialState = {
|
const initialState = {
|
||||||
result: emptyResult,
|
result: emptyResult,
|
||||||
target: '',
|
target: '',
|
||||||
|
filter: '',
|
||||||
|
offset: 0,
|
||||||
|
pageSize: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let articleSearchAutomat;
|
let articleSearchAutomat;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Matches one index record against a lowercased search term.
|
* Matches one index record against a search query (checks tokens in name, sku, brand, eans).
|
||||||
*
|
*
|
||||||
* @param {object} row Article index record.
|
* @param {object} row Article index record.
|
||||||
* @param {string} query Lowercased search term.
|
* @param {string} query Lowercased search term.
|
||||||
* @returns {boolean} Whether the record matches.
|
* @returns {boolean} Whether the record matches.
|
||||||
*/
|
*/
|
||||||
function matches(row, query) {
|
function matches(row, query) {
|
||||||
if (String(row.name ?? '').toLowerCase().includes(query)) return true;
|
if (!query) return true;
|
||||||
if (String(row.sku ?? '').toLowerCase().includes(query)) return true;
|
const tokens = query.split(/\s+/).filter(Boolean);
|
||||||
return (row.eans ?? []).some((ean) => String(ean).includes(query));
|
if (tokens.length === 0) return true;
|
||||||
|
const name = String(row.name ?? '').toLowerCase();
|
||||||
|
const sku = String(row.sku ?? '').toLowerCase();
|
||||||
|
const brand = String(row.brand?.name || row.brandName || '').toLowerCase();
|
||||||
|
const eans = (row.eans ?? []).map((e) => String(e).toLowerCase());
|
||||||
|
return tokens.every(
|
||||||
|
(token) =>
|
||||||
|
name.includes(token) ||
|
||||||
|
sku.includes(token) ||
|
||||||
|
brand.includes(token) ||
|
||||||
|
eans.some((ean) => ean.includes(token)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates a search relevance score for an article record against a query.
|
||||||
|
*
|
||||||
|
* @param {object} article Article index record.
|
||||||
|
* @param {string} query Lowercased search term.
|
||||||
|
* @returns {number} Higher score indicates a better match.
|
||||||
|
*/
|
||||||
|
export function getArticleRelevanceScore(article, query) {
|
||||||
|
if (!query) return 0;
|
||||||
|
const q = String(query).trim().toLowerCase();
|
||||||
|
const sku = String(article.sku ?? '').toLowerCase();
|
||||||
|
const name = String(article.name ?? '').toLowerCase();
|
||||||
|
const brand = String(article.brand?.name || article.brandName || '').toLowerCase();
|
||||||
|
const eans = (article.eans ?? []).map((e) => String(e).toLowerCase());
|
||||||
|
|
||||||
|
// 1. Exact SKU match
|
||||||
|
if (sku === q) return 10000;
|
||||||
|
// 2. SKU starts with query
|
||||||
|
if (sku.startsWith(q)) return 5000;
|
||||||
|
// 3. Exact Name match
|
||||||
|
if (name === q) return 4000;
|
||||||
|
// 4. Name starts with query
|
||||||
|
if (name.startsWith(q)) return 3000;
|
||||||
|
// 5. SKU contains query
|
||||||
|
if (sku.includes(q)) return 2000;
|
||||||
|
// 6. Word in name starts with query
|
||||||
|
const words = name.split(/\s+/);
|
||||||
|
if (words.some((w) => w.startsWith(q))) return 1500;
|
||||||
|
// 7. Name contains query
|
||||||
|
if (name.includes(q)) return 1000;
|
||||||
|
// 8. Exact EAN match
|
||||||
|
if (eans.includes(q)) return 800;
|
||||||
|
// 9. EAN starts with query
|
||||||
|
if (eans.some((e) => e.startsWith(q))) return 600;
|
||||||
|
// 10. Brand starts with query
|
||||||
|
if (brand.startsWith(q)) return 400;
|
||||||
|
// 11. Brand contains query
|
||||||
|
if (brand.includes(q)) return 200;
|
||||||
|
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts articles by relevance to the query, breaking ties alphabetically by name and SKU.
|
||||||
|
*
|
||||||
|
* @param {Array<object>} articles List of articles to sort.
|
||||||
|
* @param {string} query Search term.
|
||||||
|
* @returns {Array<object>} Sorted articles.
|
||||||
|
*/
|
||||||
|
export function sortArticlesByRelevance(articles, query) {
|
||||||
|
const q = String(query ?? '').trim().toLowerCase();
|
||||||
|
return [...articles].sort((a, b) => {
|
||||||
|
if (q) {
|
||||||
|
const scoreA = getArticleRelevanceScore(a, q);
|
||||||
|
const scoreB = getArticleRelevanceScore(b, q);
|
||||||
|
if (scoreA !== scoreB) {
|
||||||
|
return scoreB - scoreA;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nameA = String(a.name ?? '');
|
||||||
|
const nameB = String(b.name ?? '');
|
||||||
|
const comp = nameA.localeCompare(nameB, 'de', { sensitivity: 'base' });
|
||||||
|
if (comp !== 0) return comp;
|
||||||
|
return String(a.sku ?? '').localeCompare(String(b.sku ?? ''), 'de');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronously searches the article index using the search automat token matching and relevance order.
|
||||||
|
*
|
||||||
|
* @param {string} query Search input string.
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {Array<string>} [options.excludeSkus] SKUs to exclude from results.
|
||||||
|
* @param {boolean} [options.allowParent=false] Whether to include parent articles.
|
||||||
|
* @param {Array<string>} [options.categories] Category IDs to filter by.
|
||||||
|
* @param {number} [options.offset=0] Pagination offset.
|
||||||
|
* @param {number} [options.pageSize=10] Results per page.
|
||||||
|
* @param {number} [options.limit] Alternative to pageSize.
|
||||||
|
* @returns {Array<object>} Array of matching article index records with attached pagination metadata.
|
||||||
|
*/
|
||||||
|
export function searchArticlesIndex(query, options = {}) {
|
||||||
|
const {
|
||||||
|
excludeSkus = [],
|
||||||
|
allowParent = false,
|
||||||
|
categories = [],
|
||||||
|
offset = 0,
|
||||||
|
pageSize = 10,
|
||||||
|
limit,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (articleIndexAutomat.state.status === 'idle') {
|
||||||
|
void articleIndexAutomat.actions.ensureLoaded().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const articles = articleIndexAutomat.state.items ?? [];
|
||||||
|
const q = String(query ?? '').trim().toLowerCase();
|
||||||
|
|
||||||
|
const excludedSet = new Set((excludeSkus ?? []).map((s) => String(s).toLowerCase()));
|
||||||
|
|
||||||
|
let pool = articles;
|
||||||
|
if (!allowParent) {
|
||||||
|
pool = pool.filter((a) => !a.isParent && !a.is_parent);
|
||||||
|
}
|
||||||
|
if (excludedSet.size > 0) {
|
||||||
|
pool = pool.filter((a) => a.sku && !excludedSet.has(String(a.sku).toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let matchesList = q ? pool.filter((row) => matches(row, q)) : pool;
|
||||||
|
|
||||||
|
if (categories && categories.length > 0) {
|
||||||
|
const categoriesMap = categoryAutomat.state?.categories ?? {};
|
||||||
|
const expandedIds = getCategoryAndDescendantIds(categories, categoriesMap);
|
||||||
|
matchesList = matchesList.filter((row) => matchesCategory(row, expandedIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = sortArticlesByRelevance(matchesList, q);
|
||||||
|
const totalCount = sorted.length;
|
||||||
|
const safePageSize = limit != null ? limit : (pageSize != null && pageSize > 0 ? pageSize : 10);
|
||||||
|
const safeOffset = Math.max(0, offset || 0);
|
||||||
|
const pageCount = Math.max(1, Math.ceil(totalCount / safePageSize));
|
||||||
|
const currentPage = Math.floor(safeOffset / safePageSize) + 1;
|
||||||
|
const recordset = sorted.slice(safeOffset, safeOffset + safePageSize);
|
||||||
|
|
||||||
|
Object.assign(recordset, {
|
||||||
|
recordset,
|
||||||
|
totalCount,
|
||||||
|
pageCount,
|
||||||
|
currentPage,
|
||||||
|
offset: safeOffset,
|
||||||
|
pageSize: safePageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return recordset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether a category ID or name represents the root category.
|
||||||
|
*
|
||||||
|
* @param {string|number} id Category ID.
|
||||||
|
* @param {string|number|null} [rootCategoryId] Configured root category ID.
|
||||||
|
* @param {string} [metaName] Optional category display name.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isRootCategory(id, rootCategoryId, metaName) {
|
||||||
|
if (String(id) === '1') return true;
|
||||||
|
if (rootCategoryId != null && String(id) === String(rootCategoryId)) return true;
|
||||||
|
if (metaName) {
|
||||||
|
const lower = String(metaName).trim().toLowerCase();
|
||||||
|
if (lower === 'home' || lower === 'kategorie 1' || lower === 'category 1') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively collects categoryId and all its descendant category IDs.
|
||||||
|
*
|
||||||
|
* @param {Array<string|number>} selectedCategories Array of category ID strings.
|
||||||
|
* @param {Record<string, object>} categoriesMap Map of id -> categoryNode.
|
||||||
|
* @param {Record<string, object>} [facets] Optional fallback facet map.
|
||||||
|
* @returns {Set<string>} Set of category ID strings including selected and all descendants.
|
||||||
|
*/
|
||||||
|
export function getCategoryAndDescendantIds(selectedCategories, categoriesMap, facets) {
|
||||||
|
const allIds = new Set();
|
||||||
|
const queue = [];
|
||||||
|
|
||||||
|
for (const id of selectedCategories ?? []) {
|
||||||
|
const sId = String(id).trim();
|
||||||
|
if (sId && !allIds.has(sId)) {
|
||||||
|
allIds.add(sId);
|
||||||
|
queue.push(sId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queue.length === 0) return allIds;
|
||||||
|
|
||||||
|
if (categoriesMap && Object.keys(categoriesMap).length > 0) {
|
||||||
|
const childrenByParent = new Map();
|
||||||
|
for (const [id, node] of Object.entries(categoriesMap)) {
|
||||||
|
if (node && node.parentId != null) {
|
||||||
|
const pId = String(node.parentId);
|
||||||
|
if (!childrenByParent.has(pId)) {
|
||||||
|
childrenByParent.set(pId, []);
|
||||||
|
}
|
||||||
|
childrenByParent.get(pId).push(String(node.id ?? id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift();
|
||||||
|
const children = childrenByParent.get(current);
|
||||||
|
if (children) {
|
||||||
|
for (const childId of children) {
|
||||||
|
if (!allIds.has(childId)) {
|
||||||
|
allIds.add(childId);
|
||||||
|
queue.push(childId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (facets) {
|
||||||
|
const childrenByParent = new Map();
|
||||||
|
for (const [id, meta] of Object.entries(facets)) {
|
||||||
|
if (meta && meta.parent != null) {
|
||||||
|
const pId = String(meta.parent);
|
||||||
|
if (!childrenByParent.has(pId)) {
|
||||||
|
childrenByParent.set(pId, []);
|
||||||
|
}
|
||||||
|
childrenByParent.get(pId).push(String(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift();
|
||||||
|
const children = childrenByParent.get(current);
|
||||||
|
if (children) {
|
||||||
|
for (const childId of children) {
|
||||||
|
if (!allIds.has(childId)) {
|
||||||
|
allIds.add(childId);
|
||||||
|
queue.push(childId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether an article belongs to at least one of the target categories.
|
||||||
|
*
|
||||||
|
* @param {object} row Article index record.
|
||||||
|
* @param {Set<string>} expandedCategoryIds Set of category ID strings.
|
||||||
|
* @returns {boolean} Whether the article matches.
|
||||||
|
*/
|
||||||
|
function matchesCategory(row, expandedCategoryIds) {
|
||||||
|
if (!expandedCategoryIds || expandedCategoryIds.size === 0) return true;
|
||||||
|
if (!row.categories) return false;
|
||||||
|
const rowCats = String(row.categories).split(',').map((s) => s.trim());
|
||||||
|
return rowCats.some((catId) => expandedCategoryIds.has(String(catId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes facet counts for each category represented in matched articles.
|
||||||
|
*
|
||||||
|
* @param {Array<object>} matchedArticles Articles matching text query.
|
||||||
|
* @returns {Record<string, {count: number, name: string, parent: any}>} Category facets.
|
||||||
|
*/
|
||||||
|
function buildCategoryFacets(matchedArticles) {
|
||||||
|
const facets = {};
|
||||||
|
const categoriesMap = categoryAutomat.state?.categories ?? {};
|
||||||
|
const rootCategoryId = categoryAutomat.state?.rootCategoryId;
|
||||||
|
|
||||||
|
for (const article of matchedArticles) {
|
||||||
|
if (!article.categories) continue;
|
||||||
|
const ids = String(article.categories).split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
// Each article rolls up to its assigned categories and all their ancestors (except root).
|
||||||
|
const articleCats = new Set();
|
||||||
|
for (const id of ids) {
|
||||||
|
let currentId = id;
|
||||||
|
while (currentId && !isRootCategory(currentId, rootCategoryId)) {
|
||||||
|
articleCats.add(String(currentId));
|
||||||
|
const node = categoriesMap[String(currentId)];
|
||||||
|
currentId = node?.parentId != null && node.parentId !== 0 ? String(node.parentId) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of articleCats) {
|
||||||
|
const catNode = categoriesMap[id];
|
||||||
|
const name = catNode?.name ?? `Kategorie ${id}`;
|
||||||
|
if (isRootCategory(id, rootCategoryId, name)) continue;
|
||||||
|
|
||||||
|
if (!facets[id]) {
|
||||||
|
facets[id] = {
|
||||||
|
count: 0,
|
||||||
|
name,
|
||||||
|
parent: catNode?.parentId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
facets[id].count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return facets;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
/**
|
/**
|
||||||
* Runs a client-side search over the article index.
|
* Executes in-memory search and derivation synchronously.
|
||||||
*
|
*
|
||||||
* The index is already derived from the cached category views, so this does
|
* @param {object} [params] Search parameters to merge.
|
||||||
* not hit the network once the tree has loaded.
|
* @returns {ArticleSearchResult} Search outcome.
|
||||||
*
|
|
||||||
* @param {string} target Search term; an empty string returns everything.
|
|
||||||
* @returns {Promise<ArticleSearchResult>} Search outcome.
|
|
||||||
*/
|
*/
|
||||||
async search(target) {
|
runSearch(params = {}) {
|
||||||
articleSearchAutomat.setState({ target, loading: true });
|
const currentState = articleSearchAutomat.state;
|
||||||
try {
|
const target = params.target !== undefined ? String(params.target ?? '') : currentState.target;
|
||||||
const articles = await articleIndexAutomat.actions.ensureLoaded();
|
const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter;
|
||||||
const query = String(target ?? '').trim().toLowerCase();
|
const pageSize = params.pageSize !== undefined ? params.pageSize : currentState.pageSize;
|
||||||
const recordset = query
|
const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset;
|
||||||
|
|
||||||
|
const articles = articleIndexAutomat.state.items ?? [];
|
||||||
|
const query = target.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Filter by text query across all tokens
|
||||||
|
const textMatches = query
|
||||||
? articles.filter((row) => matches(row, query))
|
? articles.filter((row) => matches(row, query))
|
||||||
: articles;
|
: articles;
|
||||||
|
|
||||||
|
// Build facets for all text matches (before category filtering)
|
||||||
|
const categoryFacets = buildCategoryFacets(textMatches);
|
||||||
|
|
||||||
|
// Apply category filter if set (expanding to include all child categories)
|
||||||
|
const selectedCategories = parseFilter(filter);
|
||||||
|
const categoriesMap = categoryAutomat.state?.categories ?? {};
|
||||||
|
let filteredArticles = textMatches;
|
||||||
|
|
||||||
|
if (selectedCategories.length > 0) {
|
||||||
|
const expandedCategoryIds = getCategoryAndDescendantIds(
|
||||||
|
selectedCategories,
|
||||||
|
categoriesMap,
|
||||||
|
categoryFacets,
|
||||||
|
);
|
||||||
|
filteredArticles = textMatches.filter((row) =>
|
||||||
|
matchesCategory(row, expandedCategoryIds),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedArticles = sortArticlesByRelevance(filteredArticles, query);
|
||||||
|
|
||||||
|
const totalCount = sortedArticles.length;
|
||||||
|
const safePageSize = pageSize != null && pageSize > 0 ? pageSize : null;
|
||||||
|
const pageCount = safePageSize ? Math.max(1, Math.ceil(totalCount / safePageSize)) : 1;
|
||||||
|
const currentPage = safePageSize ? Math.floor(offset / safePageSize) + 1 : 1;
|
||||||
|
const recordset = safePageSize
|
||||||
|
? sortedArticles.slice(offset, offset + safePageSize)
|
||||||
|
: sortedArticles;
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
categoryFacets: {},
|
categoryFacets,
|
||||||
totalCount: recordset.length,
|
totalCount,
|
||||||
recordset,
|
recordset,
|
||||||
|
offset,
|
||||||
|
pageSize: safePageSize,
|
||||||
|
pageCount,
|
||||||
|
currentPage,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
articleSearchAutomat.setState({ result, loading: false });
|
|
||||||
|
articleSearchAutomat.setState({
|
||||||
|
result,
|
||||||
|
target,
|
||||||
|
filter,
|
||||||
|
offset,
|
||||||
|
pageSize: safePageSize,
|
||||||
|
loading: articleIndexAutomat.state.status === 'loading',
|
||||||
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
|
||||||
const result = { ...emptyResult, error };
|
|
||||||
articleSearchAutomat.setState({ result, loading: false });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Reset the result set. */
|
/**
|
||||||
|
* Runs a client-side search over the article index with pagination.
|
||||||
|
*
|
||||||
|
* Does not block on network calls: evaluates immediately against whatever
|
||||||
|
* articles are currently loaded, and ensures index derivation is running in the background.
|
||||||
|
*
|
||||||
|
* @param {string|object} [targetOrParams] Search term or options object.
|
||||||
|
* @param {object} [options] Secondary options when first argument is target string.
|
||||||
|
* @returns {Promise<ArticleSearchResult>} Search outcome.
|
||||||
|
*/
|
||||||
|
async search(targetOrParams, options = {}) {
|
||||||
|
const params = typeof targetOrParams === 'object' && targetOrParams !== null
|
||||||
|
? targetOrParams
|
||||||
|
: targetOrParams !== undefined
|
||||||
|
? { target: targetOrParams, ...options }
|
||||||
|
: options;
|
||||||
|
|
||||||
|
// Kick off index loading in the background if not ready yet
|
||||||
|
if (articleIndexAutomat.state.status === 'idle') {
|
||||||
|
void articleIndexAutomat.actions.ensureLoaded().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions.runSearch(params);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the page size and re-derives the result set.
|
||||||
|
*
|
||||||
|
* @param {number} pageSize Number of articles per page.
|
||||||
|
* @returns {ArticleSearchResult}
|
||||||
|
*/
|
||||||
|
setPageSize(pageSize) {
|
||||||
|
return actions.runSearch({ pageSize });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets page number (1-based) and re-derives the result set.
|
||||||
|
*
|
||||||
|
* @param {number} page 1-based page number.
|
||||||
|
* @returns {ArticleSearchResult}
|
||||||
|
*/
|
||||||
|
setPage(page) {
|
||||||
|
const safeSize = articleSearchAutomat.state.pageSize ?? 1;
|
||||||
|
const offset = Math.max(0, (page - 1) * safeSize);
|
||||||
|
return actions.runSearch({ offset });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets 0-based offset and re-derives the result set.
|
||||||
|
*
|
||||||
|
* @param {number} offset 0-based record offset.
|
||||||
|
* @returns {ArticleSearchResult}
|
||||||
|
*/
|
||||||
|
setOffset(offset) {
|
||||||
|
return actions.runSearch({ offset });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Reset the search state. */
|
||||||
reset() {
|
reset() {
|
||||||
articleSearchAutomat.setState(initialState);
|
articleSearchAutomat.setState(initialState);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Article search state for the `/artikel` overview page.
|
* Article search and pagination state for the `/artikel` overview page.
|
||||||
*
|
*
|
||||||
* Replaces `StorageManager.doSearch()`; components subscribe to `result`
|
* Components subscribe to `result` to receive paginated records, facets,
|
||||||
* instead of receiving a callback.
|
* page counts, and total counts.
|
||||||
*
|
*
|
||||||
* @type {Automat<ArticleSearchState>}
|
* @type {Automat<ArticleSearchState>}
|
||||||
*/
|
*/
|
||||||
articleSearchAutomat = new Automat(initialState, actions, { name: 'articleSearch' });
|
articleSearchAutomat = new Automat(initialState, actions, { name: 'articleSearch' });
|
||||||
|
|
||||||
|
// Continuously re-derive and update subscribers as category views load their article lists.
|
||||||
|
articleIndexAutomat.subscribe(() => {
|
||||||
|
actions.runSearch();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update facet names whenever the category tree is loaded/updated.
|
||||||
|
categoryAutomat.subscribe((catState) => {
|
||||||
|
if (catState.treeStatus === 'succeeded') {
|
||||||
|
actions.runSearch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selects the latest search result.
|
* Selects the latest search result.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -10,7 +10,16 @@ export { default as authAutomat } from './authAutomat';
|
|||||||
export * from './authAutomat';
|
export * from './authAutomat';
|
||||||
|
|
||||||
export { default as navigationAutomat } from './navigationAutomat';
|
export { default as navigationAutomat } from './navigationAutomat';
|
||||||
export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat';
|
export {
|
||||||
|
setLastDeliveryPath,
|
||||||
|
setLastArticleSku,
|
||||||
|
setLastSearchTerm,
|
||||||
|
setLastArticleSearchPath,
|
||||||
|
setLastCategoryId,
|
||||||
|
setLastCategorySlug,
|
||||||
|
setLastCategoryPath,
|
||||||
|
clearNavigation,
|
||||||
|
} from './navigationAutomat';
|
||||||
|
|
||||||
export { default as userSettingsAutomat } from './userSettingsAutomat';
|
export { default as userSettingsAutomat } from './userSettingsAutomat';
|
||||||
export { setViewerPosition } from './userSettingsAutomat';
|
export { setViewerPosition } from './userSettingsAutomat';
|
||||||
@@ -30,7 +39,11 @@ export { default as supplierAutomat } from './supplierAutomat';
|
|||||||
export { default as warehouseAutomat } from './warehouseAutomat';
|
export { default as warehouseAutomat } from './warehouseAutomat';
|
||||||
|
|
||||||
/** Article search over the derived index (replaces StorageManager.doSearch). */
|
/** Article search over the derived index (replaces StorageManager.doSearch). */
|
||||||
export { default as articleSearchAutomat, selectSearchResult } from './articleSearchAutomat';
|
export {
|
||||||
|
default as articleSearchAutomat,
|
||||||
|
selectSearchResult,
|
||||||
|
searchArticlesIndex,
|
||||||
|
} from './articleSearchAutomat';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derived article search index: the union of every loaded category view.
|
* Derived article search index: the union of every loaded category view.
|
||||||
|
|||||||
@@ -1,30 +1,45 @@
|
|||||||
/**
|
/**
|
||||||
* @file navigationAutomat.js
|
* @file navigationAutomat.js
|
||||||
* @description Stores the user's most recently visited delivery document path
|
* @description Stores the user's most recently visited delivery document path,
|
||||||
* and article SKU to preserve navigation context across primary section switches.
|
* article SKU, article search term/path, and category slug/path to preserve
|
||||||
|
* navigation context across primary and secondary section switches.
|
||||||
*
|
*
|
||||||
* Upstream Triggers:
|
* Upstream Triggers:
|
||||||
* - UI / Routes: Set by article and delivery views/routes via `setLastDeliveryPath(path)`,
|
* - UI / Routes: Set by article search, category navigation, and delivery views/routes
|
||||||
* `setLastArticleSku(sku)`, and `clearNavigation()`.
|
* via `setLastDeliveryPath(path)`, `setLastArticleSku(sku)`, `setLastSearchTerm(term, path)`,
|
||||||
|
* `setLastCategorySlug(slug)`, and `clearNavigation()`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Automat } from 'react-automat';
|
import { Automat } from 'react-automat';
|
||||||
|
import categoryAutomat from './categoryAutomat';
|
||||||
|
import { buildCategoryPath, resolveCategoryIdFromSlugPath } from '../components/category/categoryRoute';
|
||||||
|
import { buildSearchUrl } from '../components/article/artikelQuery';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} NavigationState
|
* @typedef {Object} NavigationState
|
||||||
* @property {string|null} lastDeliveryPath Last delivery path visited by the user.
|
* @property {string|null} lastDeliveryPath Last delivery path visited by the user.
|
||||||
* @property {string|null} lastArticleSku Last article SKU visited by the user.
|
* @property {string|null} lastArticleSku Last article SKU visited by the user.
|
||||||
|
* @property {string|null} lastSearchTerm Last article search query term.
|
||||||
|
* @property {string|null} lastArticleSearchPath Full last search path (e.g. `/artikel?target=apfel`).
|
||||||
|
* @property {string|null} lastCategoryId Last visited category ID.
|
||||||
|
* @property {string|null} lastCategorySlug Last visited category slug (e.g. `obst-gemuese/obst`).
|
||||||
|
* @property {string|null} lastCategoryPath Full last category path (e.g. `/kategorien/obst-gemuese/obst`).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @type {NavigationState} */
|
/** @type {NavigationState} */
|
||||||
const initialState = {
|
const initialState = {
|
||||||
lastDeliveryPath: null,
|
lastDeliveryPath: null,
|
||||||
lastArticleSku: null,
|
lastArticleSku: null,
|
||||||
|
lastSearchTerm: null,
|
||||||
|
lastArticleSearchPath: null,
|
||||||
|
lastCategoryId: null,
|
||||||
|
lastCategorySlug: null,
|
||||||
|
lastCategoryPath: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores the user's most recently visited delivery and article so main-menu
|
* Stores the user's most recently visited delivery, article, search term, and category
|
||||||
* navigation can restore the previous context.
|
* so main-menu and sub-menu navigation can restore the previous context.
|
||||||
*
|
*
|
||||||
* @type {Automat<NavigationState>}
|
* @type {Automat<NavigationState>}
|
||||||
*/
|
*/
|
||||||
@@ -51,16 +66,120 @@ const navigationAutomat = new Automat(
|
|||||||
navigationAutomat.setState({ lastArticleSku: sku });
|
navigationAutomat.setState({ lastArticleSku: sku });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the last active article search term and optional search URL.
|
||||||
|
*
|
||||||
|
* @param {string|null} term Search input term.
|
||||||
|
* @param {string|null} [path] Full search URL.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
setLastSearchTerm(term, path = null) {
|
||||||
|
const trimmed = term != null ? String(term) : '';
|
||||||
|
const currentCatId = navigationAutomat.state.lastCategoryId ?? '';
|
||||||
|
const searchPath = path ?? buildSearchUrl(trimmed, currentCatId, 0);
|
||||||
|
navigationAutomat.setState({
|
||||||
|
lastSearchTerm: trimmed || null,
|
||||||
|
lastArticleSearchPath: searchPath,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the full last article search URL path.
|
||||||
|
*
|
||||||
|
* @param {string|null} path Full search URL path (e.g. `/artikel?target=apfel`).
|
||||||
|
* @param {string|null} [term] Search term.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
setLastArticleSearchPath(path, term = null) {
|
||||||
|
navigationAutomat.setState({
|
||||||
|
lastArticleSearchPath: path,
|
||||||
|
...(term !== null ? { lastSearchTerm: term } : {}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the active category ID and keep categoryPath & articleSearchPath in sync.
|
||||||
|
*
|
||||||
|
* @param {string|number|null} categoryId Category ID or null for root.
|
||||||
|
* @param {string|null} [path] Full category path if already known.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
setLastCategoryId(categoryId, path = null) {
|
||||||
|
const id = categoryId != null ? String(categoryId) : null;
|
||||||
|
const categories = categoryAutomat.state?.categories;
|
||||||
|
const rootCategoryId = categoryAutomat.state?.rootCategoryId;
|
||||||
|
const isRoot = !id || (rootCategoryId != null && id === String(rootCategoryId));
|
||||||
|
|
||||||
|
let categoryPath = path;
|
||||||
|
if (!categoryPath) {
|
||||||
|
if (!isRoot && categories && rootCategoryId != null) {
|
||||||
|
categoryPath = buildCategoryPath(categories, rootCategoryId, id);
|
||||||
|
} else {
|
||||||
|
categoryPath = '/kategorien';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanSlug = categoryPath.replace(/^\/kategorien\/?/, '').replace(/\/+$/, '') || null;
|
||||||
|
const currentTerm = navigationAutomat.state.lastSearchTerm ?? '';
|
||||||
|
const filter = isRoot ? '' : id;
|
||||||
|
const searchPath = buildSearchUrl(currentTerm, filter, 0);
|
||||||
|
|
||||||
|
navigationAutomat.setState({
|
||||||
|
lastCategoryId: isRoot ? null : id,
|
||||||
|
lastCategorySlug: isRoot ? null : cleanSlug,
|
||||||
|
lastCategoryPath: categoryPath,
|
||||||
|
lastArticleSearchPath: searchPath,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the last visited category slug.
|
||||||
|
*
|
||||||
|
* @param {string|null} slug Category slug (e.g. `obst-gemuese/obst` or empty string for root).
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
setLastCategorySlug(slug) {
|
||||||
|
const clean = slug ? String(slug).replace(/^\/+|\/+$/g, '') : '';
|
||||||
|
const path = clean ? `/kategorien/${clean}` : '/kategorien';
|
||||||
|
navigationAutomat.actions.setLastCategoryPath(path);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the full last category path.
|
||||||
|
*
|
||||||
|
* @param {string|null} path Full category path (e.g. `/kategorien/obst-gemuese/obst`).
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
setLastCategoryPath(path) {
|
||||||
|
const cleanPath = path ? String(path).replace(/^\/+/, '/') : '/kategorien';
|
||||||
|
const slug = cleanPath.replace(/^\/kategorien\/?/, '').replace(/\/+$/, '') || null;
|
||||||
|
const categories = categoryAutomat.state?.categories;
|
||||||
|
const rootCategoryId = categoryAutomat.state?.rootCategoryId;
|
||||||
|
const resolvedId = slug && categories && rootCategoryId != null
|
||||||
|
? resolveCategoryIdFromSlugPath(categories, rootCategoryId, slug)
|
||||||
|
: null;
|
||||||
|
const isRoot = !resolvedId || (rootCategoryId != null && String(resolvedId) === String(rootCategoryId));
|
||||||
|
const catId = isRoot ? null : String(resolvedId);
|
||||||
|
|
||||||
|
const currentTerm = navigationAutomat.state.lastSearchTerm ?? '';
|
||||||
|
const filter = catId ?? '';
|
||||||
|
const searchPath = buildSearchUrl(currentTerm, filter, 0);
|
||||||
|
|
||||||
|
navigationAutomat.setState({
|
||||||
|
lastCategoryId: catId,
|
||||||
|
lastCategorySlug: isRoot ? null : slug,
|
||||||
|
lastCategoryPath: cleanPath,
|
||||||
|
lastArticleSearchPath: searchPath,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all navigation history.
|
* Clear all navigation history.
|
||||||
*
|
*
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
clearNavigation() {
|
clearNavigation() {
|
||||||
navigationAutomat.setState({
|
navigationAutomat.setState(initialState);
|
||||||
lastDeliveryPath: null,
|
|
||||||
lastArticleSku: null,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -70,4 +189,13 @@ const navigationAutomat = new Automat(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default navigationAutomat;
|
export default navigationAutomat;
|
||||||
export const { setLastDeliveryPath, setLastArticleSku, clearNavigation } = navigationAutomat.actions;
|
export const {
|
||||||
|
setLastDeliveryPath,
|
||||||
|
setLastArticleSku,
|
||||||
|
setLastSearchTerm,
|
||||||
|
setLastArticleSearchPath,
|
||||||
|
setLastCategoryId,
|
||||||
|
setLastCategorySlug,
|
||||||
|
setLastCategoryPath,
|
||||||
|
clearNavigation,
|
||||||
|
} = navigationAutomat.actions;
|
||||||
|
|||||||
@@ -332,6 +332,22 @@ export const SHADOW_TAB = `drop-shadow(0px 2px 4px ${shade(0.2)}) drop-shadow(0p
|
|||||||
export const SHADOW_BUBBLE_USER = `0 2px 8px ${BLUE_SHADOW}`;
|
export const SHADOW_BUBBLE_USER = `0 2px 8px ${BLUE_SHADOW}`;
|
||||||
export const SHADOW_BUBBLE_ASSISTANT = `0 1px 4px ${TINT_ROW_HOVER}`;
|
export const SHADOW_BUBBLE_ASSISTANT = `0 1px 4px ${TINT_ROW_HOVER}`;
|
||||||
|
|
||||||
|
/* ────────────────────────────────────────────────────────────────────────────
|
||||||
|
* 10b. DIALOG FOG
|
||||||
|
* Shared backdrop for every dialog (`src/components/AppDialog.jsx`).
|
||||||
|
* Darker and more bluish than `MAIN_BACKGROUND`: deep-navy solid base
|
||||||
|
* plus a translucent variant for the MUI backdrop layer. The solid
|
||||||
|
* variant is also published as `--df-dialog-fog` so the
|
||||||
|
* scrollbar-gutter placeholder can switch to the fog color while a
|
||||||
|
* dialog is open (`html.df-dialog-open` in `src/index.css`).
|
||||||
|
* -------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
/** Opaque fog base — deep navy with a blue push. */
|
||||||
|
export const DIALOG_FOG_SOLID = mix(DARK_6, CHROMA.BLUE.dark, 0.42);
|
||||||
|
|
||||||
|
/** Translucent backdrop wash painted over the page. */
|
||||||
|
export const DIALOG_FOG = alpha(DIALOG_FOG_SOLID, 0.72);
|
||||||
|
|
||||||
/* ────────────────────────────────────────────────────────────────────────────
|
/* ────────────────────────────────────────────────────────────────────────────
|
||||||
* 11. CSS VARIABLE BRIDGE
|
* 11. CSS VARIABLE BRIDGE
|
||||||
* `src/index.css` cannot import this module, so these few tokens are
|
* `src/index.css` cannot import this module, so these few tokens are
|
||||||
@@ -340,6 +356,7 @@ export const SHADOW_BUBBLE_ASSISTANT = `0 1px 4px ${TINT_ROW_HOVER}`;
|
|||||||
|
|
||||||
export const CSS_VARS = {
|
export const CSS_VARS = {
|
||||||
'--df-main-background': MAIN_BACKGROUND,
|
'--df-main-background': MAIN_BACKGROUND,
|
||||||
|
'--df-dialog-fog': DIALOG_FOG_SOLID,
|
||||||
'--df-sort-ghost': SORT_GHOST,
|
'--df-sort-ghost': SORT_GHOST,
|
||||||
'--df-sort-ghost-tint': SORT_GHOST_BG,
|
'--df-sort-ghost-tint': SORT_GHOST_BG,
|
||||||
'--df-shadow-sortable': SHADOW_SORTABLE,
|
'--df-shadow-sortable': SHADOW_SORTABLE,
|
||||||
|
|||||||
107
src/components/AppDialog.jsx
Normal file
107
src/components/AppDialog.jsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { Component } from 'react';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import { DIALOG_FOG } from '../colors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared dialog shell — drop-in replacement for MUI `Dialog`.
|
||||||
|
*
|
||||||
|
* Guarantees across the whole app:
|
||||||
|
* - instant open/close (no Fade/scale animation): `transitionDuration={0}`
|
||||||
|
* - darker, bluish fog: backdrop `backgroundColor` = `DIALOG_FOG`
|
||||||
|
* - scrollbar-gutter matches the fog: toggles `df-dialog-open` on
|
||||||
|
* `<html>` while any `AppDialog` is open (see `src/index.css`).
|
||||||
|
*
|
||||||
|
* Class component on purpose (PureComponent-free: open state is driven
|
||||||
|
* by props, state lives in the wrapped content).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import AppDialog from '../AppDialog';
|
||||||
|
* <AppDialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||||
|
*/
|
||||||
|
class AppDialog extends Component {
|
||||||
|
static openCount = 0;
|
||||||
|
|
||||||
|
static updateFogClass() {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
const html = document.documentElement;
|
||||||
|
if (!html) return;
|
||||||
|
if (AppDialog.openCount > 0) {
|
||||||
|
html.classList.add('df-dialog-open');
|
||||||
|
} else {
|
||||||
|
html.classList.remove('df-dialog-open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static enter() {
|
||||||
|
AppDialog.openCount += 1;
|
||||||
|
AppDialog.updateFogClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
static exit() {
|
||||||
|
AppDialog.openCount = Math.max(0, AppDialog.openCount - 1);
|
||||||
|
AppDialog.updateFogClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
if (this.props.open) AppDialog.enter();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (this.props.open && !prevProps.open) AppDialog.enter();
|
||||||
|
else if (!this.props.open && prevProps.open) AppDialog.exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.props.open) AppDialog.exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
transitionDuration: _transitionDuration,
|
||||||
|
TransitionProps: _transitionProps,
|
||||||
|
TransitionComponent: _transitionComponent,
|
||||||
|
slotProps: userSlotProps,
|
||||||
|
slots: userSlots,
|
||||||
|
...rest
|
||||||
|
} = this.props;
|
||||||
|
void _transitionDuration;
|
||||||
|
void _transitionProps;
|
||||||
|
void _transitionComponent;
|
||||||
|
|
||||||
|
const userBackdrop = userSlotProps?.backdrop ?? {};
|
||||||
|
const { sx: userBackdropSx, ...restBackdrop } = userBackdrop;
|
||||||
|
const userTransition = userSlotProps?.transition ?? {};
|
||||||
|
const { transition: _userTransitionSlot, ...restSlots } = userSlots ?? {};
|
||||||
|
void _userTransitionSlot;
|
||||||
|
|
||||||
|
const slotProps = {
|
||||||
|
...userSlotProps,
|
||||||
|
backdrop: {
|
||||||
|
...restBackdrop,
|
||||||
|
sx: [
|
||||||
|
{ backgroundColor: DIALOG_FOG },
|
||||||
|
...(Array.isArray(userBackdropSx)
|
||||||
|
? userBackdropSx
|
||||||
|
: userBackdropSx
|
||||||
|
? [userBackdropSx]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
transition: {
|
||||||
|
...userTransition,
|
||||||
|
timeout: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
{...rest}
|
||||||
|
slots={restSlots}
|
||||||
|
slotProps={slotProps}
|
||||||
|
transitionDuration={0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppDialog;
|
||||||
@@ -10,7 +10,7 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|||||||
import UserMenu from './UserMenu';
|
import UserMenu from './UserMenu';
|
||||||
import HelpBadge from './HelpBadge';
|
import HelpBadge from './HelpBadge';
|
||||||
import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav';
|
import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav';
|
||||||
import { artikelQueryPath } from './article/artikelQuery';
|
import { artikelQueryPath, buildSearchUrl } from './article/artikelQuery';
|
||||||
import { defaultCategoryPath } from './category/categoryRoute';
|
import { defaultCategoryPath } from './category/categoryRoute';
|
||||||
import { navigationAutomat } from '../automats';
|
import { navigationAutomat } from '../automats';
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +31,8 @@ class AppSidebar extends PureComponent {
|
|||||||
this.state = {
|
this.state = {
|
||||||
lastDeliveryPath: navigationAutomat.state.lastDeliveryPath,
|
lastDeliveryPath: navigationAutomat.state.lastDeliveryPath,
|
||||||
lastArticleSku: navigationAutomat.state.lastArticleSku,
|
lastArticleSku: navigationAutomat.state.lastArticleSku,
|
||||||
|
lastArticleSearchPath: navigationAutomat.state.lastArticleSearchPath,
|
||||||
|
lastSearchTerm: navigationAutomat.state.lastSearchTerm,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +40,8 @@ class AppSidebar extends PureComponent {
|
|||||||
this.unsub = navigationAutomat.subscribe(this, (state) => ({
|
this.unsub = navigationAutomat.subscribe(this, (state) => ({
|
||||||
lastDeliveryPath: state.lastDeliveryPath,
|
lastDeliveryPath: state.lastDeliveryPath,
|
||||||
lastArticleSku: state.lastArticleSku,
|
lastArticleSku: state.lastArticleSku,
|
||||||
|
lastArticleSearchPath: state.lastArticleSearchPath,
|
||||||
|
lastSearchTerm: state.lastSearchTerm,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +51,12 @@ class AppSidebar extends PureComponent {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { activeNav, navigate } = this.props;
|
const { activeNav, navigate } = this.props;
|
||||||
const { lastDeliveryPath, lastArticleSku } = this.state;
|
const {
|
||||||
|
lastDeliveryPath,
|
||||||
|
lastArticleSku,
|
||||||
|
lastArticleSearchPath,
|
||||||
|
lastSearchTerm,
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -79,6 +88,10 @@ class AppSidebar extends PureComponent {
|
|||||||
if (path === '/artikel') {
|
if (path === '/artikel') {
|
||||||
if (lastArticleSku) {
|
if (lastArticleSku) {
|
||||||
navigate(`/artikel/${lastArticleSku}`);
|
navigate(`/artikel/${lastArticleSku}`);
|
||||||
|
} else if (lastArticleSearchPath) {
|
||||||
|
navigate(lastArticleSearchPath);
|
||||||
|
} else if (lastSearchTerm) {
|
||||||
|
navigate(buildSearchUrl(lastSearchTerm));
|
||||||
} else {
|
} else {
|
||||||
navigate(artikelQueryPath());
|
navigate(artikelQueryPath());
|
||||||
}
|
}
|
||||||
@@ -133,8 +146,37 @@ class AppSidebar extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AppSubNav extends PureComponent {
|
class AppSubNav extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
lastArticleSearchPath: navigationAutomat.state.lastArticleSearchPath,
|
||||||
|
lastSearchTerm: navigationAutomat.state.lastSearchTerm,
|
||||||
|
lastCategoryPath: navigationAutomat.state.lastCategoryPath,
|
||||||
|
lastCategorySlug: navigationAutomat.state.lastCategorySlug,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsub = navigationAutomat.subscribe(this, (state) => ({
|
||||||
|
lastArticleSearchPath: state.lastArticleSearchPath,
|
||||||
|
lastSearchTerm: state.lastSearchTerm,
|
||||||
|
lastCategoryPath: state.lastCategoryPath,
|
||||||
|
lastCategorySlug: state.lastCategorySlug,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsub?.();
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { activeNav, activeSub, navigate } = this.props;
|
const { activeNav, activeSub, navigate } = this.props;
|
||||||
|
const {
|
||||||
|
lastArticleSearchPath,
|
||||||
|
lastSearchTerm,
|
||||||
|
lastCategoryPath,
|
||||||
|
lastCategorySlug,
|
||||||
|
} = this.state;
|
||||||
const subNav = activeNav ? SUB_NAV[activeNav] : null;
|
const subNav = activeNav ? SUB_NAV[activeNav] : null;
|
||||||
|
|
||||||
if (!subNav?.length) {
|
if (!subNav?.length) {
|
||||||
@@ -178,11 +220,19 @@ class AppSubNav extends PureComponent {
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!segment && activeNav === '/artikel') {
|
if (!segment && activeNav === '/artikel') {
|
||||||
navigate(artikelQueryPath());
|
const target =
|
||||||
|
lastArticleSearchPath ||
|
||||||
|
(lastSearchTerm ? buildSearchUrl(lastSearchTerm) : null) ||
|
||||||
|
artikelQueryPath();
|
||||||
|
navigate(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (segment === 'kategorien') {
|
if (segment === 'kategorien') {
|
||||||
navigate(defaultCategoryPath());
|
const target =
|
||||||
|
lastCategoryPath ||
|
||||||
|
(lastCategorySlug ? `/kategorien/${lastCategorySlug}` : null) ||
|
||||||
|
defaultCategoryPath();
|
||||||
|
navigate(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigate(segment ? `${activeNav}/${segment}` : activeNav);
|
navigate(segment ? `${activeNav}/${segment}` : activeNav);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Component } from 'react';
|
|||||||
import Alert from '@mui/material/Alert';
|
import Alert from '@mui/material/Alert';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from './AppDialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
@@ -180,7 +180,7 @@ export default class CompanySettingsDialog extends Component {
|
|||||||
parsedReducedtaxrate != null;
|
parsedReducedtaxrate != null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={busy ? undefined : onClose} maxWidth="sm" fullWidth>
|
<AppDialog open={open} onClose={busy ? undefined : onClose} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>Unternehmen bearbeiten</DialogTitle>
|
<DialogTitle>Unternehmen bearbeiten</DialogTitle>
|
||||||
<Box component="form" onSubmit={this.handleSubmit} noValidate>
|
<Box component="form" onSubmit={this.handleSubmit} noValidate>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -334,7 +334,7 @@ export default class CompanySettingsDialog extends Component {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Box>
|
</Box>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Box from '@mui/material/Box';
|
|||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Chip from '@mui/material/Chip';
|
import Chip from '@mui/material/Chip';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from './AppDialog';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import Divider from '@mui/material/Divider';
|
import Divider from '@mui/material/Divider';
|
||||||
@@ -268,7 +268,7 @@ class CompanySetupDialog extends Component {
|
|||||||
parsedReducedtaxrate != null;
|
parsedReducedtaxrate != null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<AppDialog
|
||||||
open
|
open
|
||||||
onClose={this.handleClose}
|
onClose={this.handleClose}
|
||||||
maxWidth={hasCompanies ? 'sm' : 'md'}
|
maxWidth={hasCompanies ? 'sm' : 'md'}
|
||||||
@@ -551,7 +551,7 @@ class CompanySetupDialog extends Component {
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from './AppDialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
@@ -65,7 +65,7 @@ class NameDialog extends Component {
|
|||||||
const canSave = name.trim() !== '';
|
const canSave = name.trim() !== '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={this.handleClose} maxWidth="xs" fullWidth>
|
<AppDialog open={open} onClose={this.handleClose} maxWidth="xs" fullWidth>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<Box component="form" onSubmit={this.handleSubmit} noValidate>
|
<Box component="form" onSubmit={this.handleSubmit} noValidate>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -97,7 +97,7 @@ class NameDialog extends Component {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Box>
|
</Box>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,14 +110,19 @@ export default class TextInput extends Component {
|
|||||||
|
|
||||||
const { input: _unusedInput, ...restSlotProps } = (slotProps || {});
|
const { input: _unusedInput, ...restSlotProps } = (slotProps || {});
|
||||||
|
|
||||||
|
const hasLabelProps = Boolean(label || InputLabelProps || slotProps?.inputLabel);
|
||||||
const mergedSlotProps = {
|
const mergedSlotProps = {
|
||||||
...restSlotProps,
|
...restSlotProps,
|
||||||
...(resolvedInputProps ? { input: resolvedInputProps } : {}),
|
...(resolvedInputProps ? { input: resolvedInputProps } : {}),
|
||||||
|
...(hasLabelProps
|
||||||
|
? {
|
||||||
inputLabel: {
|
inputLabel: {
|
||||||
...(label ? { shrink: true } : {}),
|
...(label ? { shrink: true } : {}),
|
||||||
...InputLabelProps,
|
...InputLabelProps,
|
||||||
...slotProps?.inputLabel,
|
...slotProps?.inputLabel,
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
htmlInput,
|
htmlInput,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Alert from '@mui/material/Alert';
|
|||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from '../AppDialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
@@ -317,7 +317,7 @@ export default class ArticleImageSearchDialog extends PureComponent {
|
|||||||
const isSerperKeyError = error && error.toLowerCase().includes('keys.serper');
|
const isSerperKeyError = error && error.toLowerCase().includes('keys.serper');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<AppDialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={processingId ? undefined : onClose}
|
onClose={processingId ? undefined : onClose}
|
||||||
maxWidth="md"
|
maxWidth="md"
|
||||||
@@ -719,7 +719,7 @@ export default class ArticleImageSearchDialog extends PureComponent {
|
|||||||
Schließen
|
Schließen
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from '../AppDialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
@@ -107,7 +107,7 @@ class ArticleNameDialog extends Component {
|
|||||||
const canSubmitBulk = bulkNames.length > 0;
|
const canSubmitBulk = bulkNames.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={this.handleClose} maxWidth="xs" fullWidth>
|
<AppDialog open={open} onClose={this.handleClose} maxWidth="xs" fullWidth>
|
||||||
<DialogTitle>Neuer Artikel</DialogTitle>
|
<DialogTitle>Neuer Artikel</DialogTitle>
|
||||||
|
|
||||||
{!isBulk && (
|
{!isBulk && (
|
||||||
@@ -202,7 +202,7 @@ class ArticleNameDialog extends Component {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Component, createRef } from 'react';
|
|||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Chip from '@mui/material/Chip';
|
import Chip from '@mui/material/Chip';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from '../AppDialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
@@ -271,7 +271,7 @@ class ArticlePhotosDialog extends Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={open} onClose={this.handleClose} maxWidth="md" fullWidth>
|
<AppDialog open={open} onClose={this.handleClose} maxWidth="md" fullWidth>
|
||||||
<DialogTitle>Bilder</DialogTitle>
|
<DialogTitle>Bilder</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
{photos.length === 0 ? (
|
{photos.length === 0 ? (
|
||||||
@@ -324,7 +324,7 @@ class ArticlePhotosDialog extends Component {
|
|||||||
Schließen
|
Schließen
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
<input
|
<input
|
||||||
ref={this.fileInputRef}
|
ref={this.fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import AddIcon from '@mui/icons-material/Add';
|
|||||||
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
|
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from '../AppDialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
@@ -90,7 +90,7 @@ class ArticlePriceTiersDialog extends Component {
|
|||||||
const canApply = tiersValid(tiers);
|
const canApply = tiersValid(tiers);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
<AppDialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>Staffelpreise</DialogTitle>
|
<DialogTitle>Staffelpreise</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
{tiers.length === 0 ? (
|
{tiers.length === 0 ? (
|
||||||
@@ -174,7 +174,7 @@ class ArticlePriceTiersDialog extends Component {
|
|||||||
Übernehmen
|
Übernehmen
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
878
src/components/article/ArticleSearch.jsx
Normal file
878
src/components/article/ArticleSearch.jsx
Normal file
@@ -0,0 +1,878 @@
|
|||||||
|
import { PureComponent, createRef } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import AppDialog from '../AppDialog';
|
||||||
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
||||||
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import TextInput from '../TextInput';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Autocomplete from '@mui/material/Autocomplete';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||||
|
import FillSpace from 'dashgui/FillSpace';
|
||||||
|
import articleIndexAutomat from '../../automats/articleIndexAutomat';
|
||||||
|
import { searchArticlesIndex } from '../../automats/articleSearchAutomat';
|
||||||
|
import ArticlePhoto from './ArticlePhoto';
|
||||||
|
import { BrandLabel } from '../brand';
|
||||||
|
import { formatPrice } from '../../utils/price';
|
||||||
|
import { formatStock } from './searchFormat';
|
||||||
|
import ResultsPagination from './ResultsPagination';
|
||||||
|
import { BORDER } from '../../colors';
|
||||||
|
|
||||||
|
const searchResultRowSx = {
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: 64,
|
||||||
|
maxHeight: 64,
|
||||||
|
height: 64,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
py: 0,
|
||||||
|
px: 2,
|
||||||
|
'& .MuiListItemText-root': { minWidth: 0, my: 0 },
|
||||||
|
'& .MuiListItemText-primary': {
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
lineHeight: '22px',
|
||||||
|
height: '22px',
|
||||||
|
},
|
||||||
|
'& .MuiListItemText-secondary': {
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
lineHeight: '20px',
|
||||||
|
height: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Measure row that precisely matches the rendered result row height. */
|
||||||
|
function SearchRowMeasureSample() {
|
||||||
|
return (
|
||||||
|
<ListItemButton
|
||||||
|
tabIndex={-1}
|
||||||
|
divider
|
||||||
|
disabled
|
||||||
|
sx={{
|
||||||
|
...searchResultRowSx,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemAvatar sx={{ minWidth: 48 }}>
|
||||||
|
<Box sx={{ width: 38, height: 38 }} />
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText
|
||||||
|
primary={'\u00A0'}
|
||||||
|
secondary={
|
||||||
|
<Typography component="span" variant="body2" sx={{ visibility: 'hidden', lineHeight: '20px', height: '20px' }} noWrap>
|
||||||
|
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Box sx={{ width: 140, height: 28, visibility: 'hidden' }} />
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified article search component based on articleSearchAutomat / articleIndexAutomat.
|
||||||
|
*
|
||||||
|
* Supported Modes:
|
||||||
|
* 1. 'dialog' (default): Inline trigger field that opens a spacious modal dialog.
|
||||||
|
* 2. 'list': Standalone search input with rich results list and dynamic FillSpace pagination.
|
||||||
|
* 3. 'autocomplete': Standard Autocomplete with drop-down popper.
|
||||||
|
*
|
||||||
|
* @param {object} props
|
||||||
|
* @param {'dialog'|'list'|'autocomplete'} [props.mode='dialog']
|
||||||
|
* @param {string} [props.placeholder='Artikel suchen…']
|
||||||
|
* @param {string} [props.label]
|
||||||
|
* @param {object|null} [props.value] Currently selected article.
|
||||||
|
* @param {Function} [props.onSelect] Callback when article selected: (article) => void.
|
||||||
|
* @param {Function} [props.onChange] Alternative callback for selection.
|
||||||
|
* @param {Array<string>} [props.excludeSkus=[]] SKUs to exclude.
|
||||||
|
* @param {boolean} [props.allowParent=false] Whether parent articles are selectable.
|
||||||
|
* @param {Array<string>} [props.categories=[]] Category IDs to filter by.
|
||||||
|
* @param {number} [props.pageSize] Initial results per page (measured dynamically by FillSpace).
|
||||||
|
* @param {boolean} [props.clearOnSelect=false]
|
||||||
|
* @param {boolean} [props.disabled=false]
|
||||||
|
* @param {boolean} [props.autoFocus=false]
|
||||||
|
* @param {'small'|'medium'} [props.size='small']
|
||||||
|
* @param {object} [props.sx]
|
||||||
|
* @param {boolean} [props.open] For controlled dialog: whether dialog is open.
|
||||||
|
* @param {Function} [props.onClose] For controlled dialog: close callback.
|
||||||
|
* @param {string} [props.title] Dialog title.
|
||||||
|
* @param {string} [props.actionLabel='Auswählen'] Label on selection button.
|
||||||
|
*/
|
||||||
|
export default class ArticleSearch extends PureComponent {
|
||||||
|
headerRef = createRef();
|
||||||
|
paginationRef = createRef();
|
||||||
|
listContainerRef = createRef();
|
||||||
|
contentContainerRef = createRef();
|
||||||
|
fillSpaceRef = createRef();
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
const initialQuery = props.initialQuery ?? '';
|
||||||
|
const pageSize = props.pageSize ?? 8;
|
||||||
|
this.state = {
|
||||||
|
query: initialQuery,
|
||||||
|
offset: 0,
|
||||||
|
pageSize,
|
||||||
|
totalCount: 0,
|
||||||
|
pageCount: 1,
|
||||||
|
currentPage: 1,
|
||||||
|
searchKey: 0,
|
||||||
|
results: [],
|
||||||
|
indexStatus: articleIndexAutomat.state.status,
|
||||||
|
busySku: props.busySku || null,
|
||||||
|
error: null,
|
||||||
|
internalOpen: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsubIndex = articleIndexAutomat.subscribe(this, (state) => ({
|
||||||
|
indexStatus: state.status,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (articleIndexAutomat.state.status === 'idle') {
|
||||||
|
void articleIndexAutomat.actions.ensureLoaded().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.runQuery(this.state.query, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps, prevState) {
|
||||||
|
if (
|
||||||
|
this.state.indexStatus !== prevState.indexStatus ||
|
||||||
|
this.props.excludeSkus !== prevProps.excludeSkus ||
|
||||||
|
this.props.allowParent !== prevProps.allowParent ||
|
||||||
|
this.props.categories !== prevProps.categories
|
||||||
|
) {
|
||||||
|
this.runQuery(this.state.query, this.state.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.props.busySku !== prevProps.busySku) {
|
||||||
|
this.setState({ busySku: this.props.busySku });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.props.initialQuery !== prevProps.initialQuery && this.props.initialQuery != null) {
|
||||||
|
this.setState({ query: this.props.initialQuery, offset: 0 });
|
||||||
|
this.runQuery(this.props.initialQuery, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubIndex?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMeasurement = (count) => {
|
||||||
|
if (!count || count <= 0 || count === this.state.pageSize) return;
|
||||||
|
this.setState({ pageSize: count });
|
||||||
|
this.runQuery(this.state.query, this.state.offset, count);
|
||||||
|
};
|
||||||
|
|
||||||
|
runQuery = (queryText, newOffset = 0, customPageSize) => {
|
||||||
|
const {
|
||||||
|
excludeSkus = [],
|
||||||
|
allowParent = false,
|
||||||
|
categories = [],
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const pageSize = customPageSize ?? this.state.pageSize;
|
||||||
|
|
||||||
|
const res = searchArticlesIndex(queryText, {
|
||||||
|
excludeSkus,
|
||||||
|
allowParent,
|
||||||
|
categories,
|
||||||
|
offset: newOffset,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
results: Array.isArray(res) ? res : (res.recordset ?? []),
|
||||||
|
totalCount: res.totalCount ?? res.length,
|
||||||
|
pageCount: res.pageCount ?? 1,
|
||||||
|
currentPage: res.currentPage ?? 1,
|
||||||
|
offset: newOffset,
|
||||||
|
query: queryText,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
handlePageChange = (_e, page) => {
|
||||||
|
const nextOffset = (page - 1) * this.state.pageSize;
|
||||||
|
this.runQuery(this.state.query, nextOffset);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleOpenDialog = () => {
|
||||||
|
const initialQuery = this.props.initialQuery ?? '';
|
||||||
|
this.setState({ internalOpen: true, query: initialQuery, offset: 0 });
|
||||||
|
this.runQuery(initialQuery, 0);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.fillSpaceRef.current?.measure();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCloseDialog = () => {
|
||||||
|
this.setState({ internalOpen: false });
|
||||||
|
this.props.onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSelectArticle = (article) => {
|
||||||
|
const { onSelect, onChange, clearOnSelect } = this.props;
|
||||||
|
|
||||||
|
if (article) {
|
||||||
|
this.setState({ internalOpen: false });
|
||||||
|
this.props.onClose?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearOnSelect) {
|
||||||
|
this.setState((prev) => ({
|
||||||
|
query: '',
|
||||||
|
offset: 0,
|
||||||
|
searchKey: prev.searchKey + 1,
|
||||||
|
}));
|
||||||
|
this.runQuery('', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect?.(article);
|
||||||
|
onChange?.(article);
|
||||||
|
};
|
||||||
|
|
||||||
|
renderTrigger() {
|
||||||
|
const {
|
||||||
|
placeholder = '',
|
||||||
|
value = null,
|
||||||
|
disabled = false,
|
||||||
|
size = 'small',
|
||||||
|
sx,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const displayValue = value
|
||||||
|
? typeof value === 'string'
|
||||||
|
? value
|
||||||
|
: (value.sku ? `${value.sku} - ${value.name || ''}` : (value.name || ''))
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
fullWidth
|
||||||
|
size={size}
|
||||||
|
placeholder={placeholder || ''}
|
||||||
|
value={displayValue}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled) this.handleOpenDialog();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!disabled) this.handleOpenDialog();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
readOnly
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
notched: false,
|
||||||
|
readOnly: true,
|
||||||
|
sx: {
|
||||||
|
'& .MuiOutlinedInput-notchedOutline legend': {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
cursor: disabled ? 'default' : 'pointer',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
'& input': {
|
||||||
|
cursor: disabled ? 'default' : 'pointer',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon fontSize="small" color="action" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
endAdornment: value ? (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="Auswahl aufheben"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.handleSelectArticle(null);
|
||||||
|
}}
|
||||||
|
edge="end"
|
||||||
|
>
|
||||||
|
<CloseIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
cursor: disabled ? 'default' : 'pointer',
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderListContent() {
|
||||||
|
const {
|
||||||
|
placeholder = '',
|
||||||
|
autoFocus = true,
|
||||||
|
actionLabel = 'Auswählen',
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
query,
|
||||||
|
results,
|
||||||
|
busySku,
|
||||||
|
error,
|
||||||
|
offset,
|
||||||
|
pageSize,
|
||||||
|
totalCount,
|
||||||
|
pageCount,
|
||||||
|
currentPage,
|
||||||
|
} = this.state;
|
||||||
|
const loading = this.state.indexStatus === 'loading';
|
||||||
|
const showPagination = totalCount > pageSize;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={this.listContainerRef}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1.5,
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 1, flexShrink: 0 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box ref={this.headerRef} sx={{ flexShrink: 0 }}>
|
||||||
|
<TextInput
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
readOnly={false}
|
||||||
|
placeholder={placeholder || ''}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
this.setState({ query: val, offset: 0 });
|
||||||
|
this.runQuery(val, 0);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape' && query) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.setState({ query: '', offset: 0 });
|
||||||
|
this.runQuery('', 0);
|
||||||
|
} else if (e.key === 'Enter' && results.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.handleSelectArticle(results[0]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={Boolean(busySku)}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
notched: false,
|
||||||
|
sx: {
|
||||||
|
'& .MuiOutlinedInput-notchedOutline legend': {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon fontSize="small" color="action" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
endAdornment: query ? (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="Eingabe leeren"
|
||||||
|
onClick={() => {
|
||||||
|
this.setState({ query: '', offset: 0 });
|
||||||
|
this.runQuery('', 0);
|
||||||
|
}}
|
||||||
|
edge="end"
|
||||||
|
>
|
||||||
|
<CloseIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box ref={this.paginationRef} sx={{ px: 0.5, flexShrink: 0 }}>
|
||||||
|
<ResultsPagination
|
||||||
|
invisible={!showPagination}
|
||||||
|
offset={showPagination ? offset : 0}
|
||||||
|
totalCount={showPagination ? totalCount : pageSize + 1}
|
||||||
|
pageSize={pageSize}
|
||||||
|
pageCount={showPagination ? pageCount : 1}
|
||||||
|
currentPage={showPagination ? currentPage : 1}
|
||||||
|
onPageChange={this.handlePageChange}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<FillSpace
|
||||||
|
ref={this.fillSpaceRef}
|
||||||
|
aboveRef={[this.headerRef, this.paginationRef]}
|
||||||
|
containerRef={this.contentContainerRef.current ? this.contentContainerRef : this.listContainerRef}
|
||||||
|
onMeasurement={this.handleMeasurement}
|
||||||
|
measureElement={<SearchRowMeasureSample />}
|
||||||
|
minHeight={180}
|
||||||
|
sx={{ overflow: 'auto', flex: 1, minHeight: 0 }}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', py: 6, gap: 1.5 }}>
|
||||||
|
<CircularProgress size={22} />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Artikel werden geladen…
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : results.length === 0 ? null : (
|
||||||
|
<List
|
||||||
|
dense
|
||||||
|
disablePadding
|
||||||
|
sx={{
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{results.map((article) => {
|
||||||
|
const photo = article.photos?.[0] || article.articlepic?.[0];
|
||||||
|
const fullHash = photo?.hash || photo?.fullHash;
|
||||||
|
const thumbHash = photo?.thumbhash || photo?.thumbHash;
|
||||||
|
const thumbW = photo?.thumbw || photo?.thumbW;
|
||||||
|
const thumbH = photo?.thumbh || photo?.thumbH;
|
||||||
|
const hasPhoto = fullHash != null || thumbHash != null;
|
||||||
|
const primaryEan = Array.isArray(article.eans) ? article.eans[0] : article.eans;
|
||||||
|
const price = article.price ?? article.vknetto ?? article.net;
|
||||||
|
const isBusy = busySku === article.sku;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItemButton
|
||||||
|
key={article.id || article.sku}
|
||||||
|
divider
|
||||||
|
disabled={Boolean(busySku)}
|
||||||
|
onClick={() => this.handleSelectArticle(article)}
|
||||||
|
sx={searchResultRowSx}
|
||||||
|
>
|
||||||
|
<ListItemAvatar sx={{ minWidth: 48 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: BORDER,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasPhoto ? (
|
||||||
|
<ArticlePhoto
|
||||||
|
photo={{ fullHash, thumbHash, thumbW, thumbH }}
|
||||||
|
size={38}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Inventory2OutlinedIcon sx={{ fontSize: 20, color: 'text.secondary' }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</ListItemAvatar>
|
||||||
|
|
||||||
|
<ListItemText
|
||||||
|
primary={article.name || '–'}
|
||||||
|
secondary={
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
noWrap
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
maxWidth: '100%',
|
||||||
|
lineHeight: '20px',
|
||||||
|
height: '20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(article.brand?.name || article.brandName) && (
|
||||||
|
<>
|
||||||
|
<BrandLabel
|
||||||
|
brand={article.brand || { name: article.brandName, thumbHash: article.brandThumbHash }}
|
||||||
|
name={article.brand?.name || article.brandName}
|
||||||
|
size={14}
|
||||||
|
spacing={0.5}
|
||||||
|
typographyProps={{
|
||||||
|
variant: 'body2',
|
||||||
|
color: 'text.secondary',
|
||||||
|
noWrap: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{' · '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{article.sku}
|
||||||
|
{article.stock != null && ` · Bestand ${formatStock(article.stock)}`}
|
||||||
|
{article.available != null && ` · Verfügbar ${formatStock(article.available)}`}
|
||||||
|
{primaryEan ? ` · EAN ${primaryEan}` : ''}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, ml: 2, flexShrink: 0 }}>
|
||||||
|
{price != null && (
|
||||||
|
<Typography variant="body2" fontWeight={700} color="text.primary">
|
||||||
|
{formatPrice(price)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={Boolean(busySku)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.handleSelectArticle(article);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
textTransform: 'none',
|
||||||
|
py: 0.5,
|
||||||
|
px: 1.75,
|
||||||
|
minWidth: 84,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isBusy ? 'Laden…' : actionLabel}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</FillSpace>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDialogModal(isControlled) {
|
||||||
|
const {
|
||||||
|
open: controlledOpen,
|
||||||
|
title = this.props.label || 'Artikel suchen',
|
||||||
|
} = this.props;
|
||||||
|
const { internalOpen } = this.state;
|
||||||
|
const isDialogOpen = isControlled ? Boolean(controlledOpen) : internalOpen;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppDialog
|
||||||
|
open={isDialogOpen}
|
||||||
|
onClose={this.handleCloseDialog}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
height: '80vh',
|
||||||
|
minHeight: '80vh',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
sx: {
|
||||||
|
height: '80vh',
|
||||||
|
minHeight: '80vh',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
pb: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" component="span" fontWeight={600}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<IconButton size="small" onClick={this.handleCloseDialog} aria-label="Schließen">
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent
|
||||||
|
ref={this.contentContainerRef}
|
||||||
|
dividers
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.renderListContent()}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 2, py: 1.5, flexShrink: 0 }}>
|
||||||
|
<Button size="small" onClick={this.handleCloseDialog}>
|
||||||
|
Schließen
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</AppDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAutocomplete() {
|
||||||
|
const {
|
||||||
|
placeholder = 'Artikel suchen…',
|
||||||
|
label,
|
||||||
|
value = null,
|
||||||
|
disabled = false,
|
||||||
|
autoFocus = false,
|
||||||
|
size = 'small',
|
||||||
|
clearOnSelect = false,
|
||||||
|
sx,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const { results } = this.state;
|
||||||
|
const loading = this.state.indexStatus === 'loading';
|
||||||
|
const options = value && !results.some((r) => r.sku === value.sku) ? [value, ...results] : results;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
key={this.state.searchKey}
|
||||||
|
size={size}
|
||||||
|
disabled={disabled}
|
||||||
|
options={options}
|
||||||
|
value={clearOnSelect ? null : (value || null)}
|
||||||
|
onInputChange={(_e, newInputValue, reason) => {
|
||||||
|
if (reason === 'input' || reason === 'clear') {
|
||||||
|
this.runQuery(newInputValue, 0);
|
||||||
|
} else if (reason === 'reset' && !newInputValue) {
|
||||||
|
this.runQuery('', 0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={(_e, selected) => {
|
||||||
|
this.handleSelectArticle(selected);
|
||||||
|
}}
|
||||||
|
getOptionLabel={(opt) =>
|
||||||
|
typeof opt === 'string'
|
||||||
|
? opt
|
||||||
|
: (opt ? `${opt.sku} - ${opt.name || ''}` : '')
|
||||||
|
}
|
||||||
|
isOptionEqualToValue={(opt, val) => opt?.sku === val?.sku}
|
||||||
|
filterOptions={(x) => x}
|
||||||
|
loading={loading}
|
||||||
|
renderOption={(props, article) => {
|
||||||
|
const { key, ...otherProps } = props;
|
||||||
|
const photo = article.photos?.[0] || article.articlepic?.[0];
|
||||||
|
const fullHash = photo?.hash || photo?.fullHash;
|
||||||
|
const thumbHash = photo?.thumbhash || photo?.thumbHash;
|
||||||
|
const thumbW = photo?.thumbw || photo?.thumbW;
|
||||||
|
const thumbH = photo?.thumbh || photo?.thumbH;
|
||||||
|
const hasPhoto = fullHash != null || thumbHash != null;
|
||||||
|
const price = article.price ?? article.vknetto ?? article.net;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="li"
|
||||||
|
key={article.id || article.sku || key}
|
||||||
|
{...otherProps}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.25,
|
||||||
|
minHeight: 52,
|
||||||
|
maxHeight: 52,
|
||||||
|
height: 52,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
py: 0,
|
||||||
|
px: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: BORDER,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasPhoto ? (
|
||||||
|
<ArticlePhoto
|
||||||
|
photo={{ fullHash, thumbHash, thumbW, thumbH }}
|
||||||
|
size={32}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Inventory2OutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={article.sku}
|
||||||
|
sx={{
|
||||||
|
height: 18,
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" fontWeight={600} noWrap sx={{ flex: 1 }}>
|
||||||
|
{article.name || '–'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.25, height: 18, minHeight: 18 }}>
|
||||||
|
{(article.brand?.name || article.brandName) && (
|
||||||
|
<BrandLabel
|
||||||
|
brand={article.brand || { name: article.brandName, thumbHash: article.brandThumbHash }}
|
||||||
|
name={article.brand?.name || article.brandName}
|
||||||
|
size={13}
|
||||||
|
typographyProps={{ variant: 'caption', color: 'text.secondary', noWrap: true }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{article.stock != null && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Bestand: {formatStock(article.stock)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{price != null && (
|
||||||
|
<Typography variant="body2" fontWeight={600} sx={{ flexShrink: 0, ml: 1 }}>
|
||||||
|
{formatPrice(price)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderInput={(params) => {
|
||||||
|
const inputProps = params.InputProps ?? params.slotProps?.input ?? {};
|
||||||
|
const startAdornment = (
|
||||||
|
<>
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon fontSize="small" color="action" />
|
||||||
|
</InputAdornment>
|
||||||
|
{inputProps.startAdornment}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const endAdornment = (
|
||||||
|
<>
|
||||||
|
{loading ? <CircularProgress color="inherit" size={16} /> : null}
|
||||||
|
{inputProps.endAdornment}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (params.InputProps) {
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
{...params}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
placeholder={placeholder || ''}
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
startAdornment,
|
||||||
|
endAdornment,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
{...params}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
placeholder={placeholder || ''}
|
||||||
|
slotProps={{
|
||||||
|
...params.slotProps,
|
||||||
|
input: {
|
||||||
|
...params.slotProps?.input,
|
||||||
|
startAdornment,
|
||||||
|
endAdornment,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
sx={sx}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { mode = 'dialog', open } = this.props;
|
||||||
|
|
||||||
|
if (mode === 'list') {
|
||||||
|
return this.renderListContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'autocomplete') {
|
||||||
|
return this.renderAutocomplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
const isControlled = open !== undefined;
|
||||||
|
|
||||||
|
if (isControlled) {
|
||||||
|
return this.renderDialogModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{this.renderTrigger()}
|
||||||
|
{this.renderDialogModal(false)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import Box from '@mui/material/Box';
|
|||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Chip from '@mui/material/Chip';
|
import Chip from '@mui/material/Chip';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from '../AppDialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
@@ -307,7 +307,7 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
'';
|
'';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<AppDialog
|
||||||
open={Boolean(open)}
|
open={Boolean(open)}
|
||||||
onClose={submitting ? undefined : onClose}
|
onClose={submitting ? undefined : onClose}
|
||||||
maxWidth="sm"
|
maxWidth="sm"
|
||||||
@@ -624,7 +624,7 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
: 'Speichern'}
|
: 'Speichern'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { PureComponent } from 'react';
|
|||||||
import Autocomplete from '@mui/material/Autocomplete';
|
import Autocomplete from '@mui/material/Autocomplete';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from '../AppDialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
@@ -111,7 +111,7 @@ export default class ArticleSupplierDialog extends PureComponent {
|
|||||||
const puValid = parsedPu != null && parsedPu > 0;
|
const puValid = parsedPu != null && parsedPu > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
<AppDialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
<form onSubmit={this.handleSubmit}>
|
<form onSubmit={this.handleSubmit}>
|
||||||
<DialogTitle sx={{ fontWeight: 600 }}>
|
<DialogTitle sx={{ fontWeight: 600 }}>
|
||||||
{isEdit ? 'Lieferant bearbeiten' : 'Lieferant zuweisen'}
|
{isEdit ? 'Lieferant bearbeiten' : 'Lieferant zuweisen'}
|
||||||
@@ -226,7 +226,7 @@ export default class ArticleSupplierDialog extends PureComponent {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Box from '@mui/material/Box';
|
|||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Chip from '@mui/material/Chip';
|
import Chip from '@mui/material/Chip';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from '../AppDialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
@@ -165,7 +165,7 @@ export default class ArticleUnitEditorDialog extends Component {
|
|||||||
const loading = unitsLoading && units.length === 0;
|
const loading = unitsLoading && units.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={busy ? undefined : onClose} maxWidth="md" fullWidth>
|
<AppDialog open={open} onClose={busy ? undefined : onClose} maxWidth="md" fullWidth>
|
||||||
<DialogTitle>Einheiten</DialogTitle>
|
<DialogTitle>Einheiten</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||||
@@ -299,7 +299,7 @@ export default class ArticleUnitEditorDialog extends Component {
|
|||||||
Schließen
|
Schließen
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PureComponent } from 'react';
|
import { PureComponent } from 'react';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from '../AppDialog';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
@@ -145,7 +145,7 @@ export default class CategoryPickerDialog extends PureComponent {
|
|||||||
: EMPTY_ARRAY;
|
: EMPTY_ARRAY;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<AppDialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
maxWidth="sm"
|
maxWidth="sm"
|
||||||
@@ -273,7 +273,7 @@ export default class CategoryPickerDialog extends PureComponent {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Button from '@mui/material/Button';
|
|||||||
import Checkbox from '@mui/material/Checkbox';
|
import Checkbox from '@mui/material/Checkbox';
|
||||||
import Chip from '@mui/material/Chip';
|
import Chip from '@mui/material/Chip';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from '../../AppDialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogContentText from '@mui/material/DialogContentText';
|
import DialogContentText from '@mui/material/DialogContentText';
|
||||||
@@ -18,6 +18,7 @@ import MenuItem from '@mui/material/MenuItem';
|
|||||||
import Tab from '@mui/material/Tab';
|
import Tab from '@mui/material/Tab';
|
||||||
import Tabs from '@mui/material/Tabs';
|
import Tabs from '@mui/material/Tabs';
|
||||||
import TextInput from '../../TextInput';
|
import TextInput from '../../TextInput';
|
||||||
|
import ArticleSearch from '../ArticleSearch';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
@@ -611,6 +612,7 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderStuecklisteContent(stuecklisteItems, selectableOtherArticles, isStueckliste = true) {
|
renderStuecklisteContent(stuecklisteItems, selectableOtherArticles, isStueckliste = true) {
|
||||||
|
const { article } = this.props;
|
||||||
const { busy, stuecklisteTargetArticle, stuecklisteCount } = this.state;
|
const { busy, stuecklisteTargetArticle, stuecklisteCount } = this.state;
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, flex: 1, minHeight: 0 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, flex: 1, minHeight: 0 }}>
|
||||||
@@ -663,23 +665,14 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
|
|
||||||
{/* Add Component Row */}
|
{/* Add Component Row */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
||||||
<Autocomplete
|
<ArticleSearch
|
||||||
size="small"
|
size="small"
|
||||||
options={selectableOtherArticles}
|
|
||||||
value={stuecklisteTargetArticle}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(_e, val) => this.setState({ stuecklisteTargetArticle: val })}
|
|
||||||
getOptionLabel={(opt) =>
|
|
||||||
typeof opt === 'string' ? opt : (opt ? `${opt.sku} - ${opt.name || ''}` : '')
|
|
||||||
}
|
|
||||||
isOptionEqualToValue={(opt, val) => opt?.sku === val?.sku}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextInput
|
|
||||||
{...params}
|
|
||||||
label="Artikel"
|
label="Artikel"
|
||||||
placeholder="Artikel suchen..."
|
placeholder="Artikel suchen..."
|
||||||
/>
|
value={stuecklisteTargetArticle}
|
||||||
)}
|
disabled={busy}
|
||||||
|
excludeSkus={[article?.sku, ...stuecklisteItems.map((item) => item.component_sku || item.sku)]}
|
||||||
|
onSelect={(val) => this.setState({ stuecklisteTargetArticle: val })}
|
||||||
sx={{ flex: 1, minWidth: 0 }}
|
sx={{ flex: 1, minWidth: 0 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -807,6 +800,7 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderOptionsContent(optionsItems, selectableOtherArticles, hasOptions = true) {
|
renderOptionsContent(optionsItems, selectableOtherArticles, hasOptions = true) {
|
||||||
|
const { article } = this.props;
|
||||||
const { busy, optionTargetArticle, optionCount, optionMandated } = this.state;
|
const { busy, optionTargetArticle, optionCount, optionMandated } = this.state;
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, flex: 1, minHeight: 0 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, flex: 1, minHeight: 0 }}>
|
||||||
@@ -860,23 +854,14 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
|
|
||||||
{/* Add Option Row */}
|
{/* Add Option Row */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
||||||
<Autocomplete
|
<ArticleSearch
|
||||||
size="small"
|
size="small"
|
||||||
options={selectableOtherArticles}
|
|
||||||
value={optionTargetArticle}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(_e, val) => this.setState({ optionTargetArticle: val })}
|
|
||||||
getOptionLabel={(opt) =>
|
|
||||||
typeof opt === 'string' ? opt : (opt ? `${opt.sku} - ${opt.name || ''}` : '')
|
|
||||||
}
|
|
||||||
isOptionEqualToValue={(opt, val) => opt?.sku === val?.sku}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextInput
|
|
||||||
{...params}
|
|
||||||
label="Option"
|
label="Option"
|
||||||
placeholder="Option suchen..."
|
placeholder="Option suchen..."
|
||||||
/>
|
value={optionTargetArticle}
|
||||||
)}
|
disabled={busy}
|
||||||
|
excludeSkus={[article?.sku, ...optionsItems.map((item) => item.component_sku || item.sku)]}
|
||||||
|
onSelect={(val) => this.setState({ optionTargetArticle: val })}
|
||||||
sx={{ flex: 1, minWidth: 0 }}
|
sx={{ flex: 1, minWidth: 0 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1768,7 +1753,7 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Remove Child Confirmation Dialog */}
|
{/* Remove Child Confirmation Dialog */}
|
||||||
<Dialog
|
<AppDialog
|
||||||
open={Boolean(removeTargetChild)}
|
open={Boolean(removeTargetChild)}
|
||||||
onClose={this.handleCloseRemoveDialog}
|
onClose={this.handleCloseRemoveDialog}
|
||||||
maxWidth="xs"
|
maxWidth="xs"
|
||||||
@@ -1808,10 +1793,10 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
Vollständig löschen
|
Vollständig löschen
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
|
|
||||||
{/* Clear Stückliste / Optionen Confirmation Dialog */}
|
{/* Clear Stückliste / Optionen Confirmation Dialog */}
|
||||||
<Dialog
|
<AppDialog
|
||||||
open={Boolean(confirmClearType)}
|
open={Boolean(confirmClearType)}
|
||||||
onClose={() => this.setState({ confirmClearType: null })}
|
onClose={() => this.setState({ confirmClearType: null })}
|
||||||
maxWidth="xs"
|
maxWidth="xs"
|
||||||
@@ -1850,7 +1835,7 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
Auflösen
|
Auflösen
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
</PanelTile>
|
</PanelTile>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export { default as ArticlePhotosDialog } from './ArticlePhotosDialog';
|
|||||||
export { default as ArticleImageSearchDialog } from './ArticleImageSearchDialog';
|
export { default as ArticleImageSearchDialog } from './ArticleImageSearchDialog';
|
||||||
export { default as ArticleTileTab } from './ArticleTileTab';
|
export { default as ArticleTileTab } from './ArticleTileTab';
|
||||||
export { default as NewArticleTile } from './NewArticleTile';
|
export { default as NewArticleTile } from './NewArticleTile';
|
||||||
|
export { default as ArticleSearch } from './ArticleSearch';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getCategoryBreadcrumbs, ROOT_CATEGORY_NAME } from '../../utils/categoryUtils';
|
||||||
|
|
||||||
export function parseFilter(filterStr) {
|
export function parseFilter(filterStr) {
|
||||||
if (!filterStr) return [];
|
if (!filterStr) return [];
|
||||||
return filterStr.split(',').map((s) => s.trim()).filter(Boolean);
|
return filterStr.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
@@ -15,13 +17,62 @@ export function formatStock(value) {
|
|||||||
export function getCategoryDepth(id, facets) {
|
export function getCategoryDepth(id, facets) {
|
||||||
let depth = 0;
|
let depth = 0;
|
||||||
let parent = facets[id]?.parent;
|
let parent = facets[id]?.parent;
|
||||||
while (parent && parent !== 0 && facets[String(parent)]) {
|
while (
|
||||||
|
parent &&
|
||||||
|
parent !== 0 &&
|
||||||
|
String(parent) !== '1' &&
|
||||||
|
facets[String(parent)]
|
||||||
|
) {
|
||||||
depth += 1;
|
depth += 1;
|
||||||
parent = facets[String(parent)]?.parent;
|
parent = facets[String(parent)]?.parent;
|
||||||
}
|
}
|
||||||
return depth;
|
return depth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCategoryBreadcrumbLabel(catId, categories, rootCategoryId, facets) {
|
||||||
|
if (categories && categories[String(catId)]) {
|
||||||
|
const crumbs = getCategoryBreadcrumbs(categories, catId, rootCategoryId);
|
||||||
|
const visible = crumbs.filter((c) => {
|
||||||
|
if (String(c.id) === '1') return false;
|
||||||
|
if (rootCategoryId != null && String(c.id) === String(rootCategoryId)) return false;
|
||||||
|
if (!c.name) return false;
|
||||||
|
const lower = c.name.trim().toLowerCase();
|
||||||
|
if (lower === 'home' || lower === 'kategorie 1' || lower === 'category 1') return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (visible.length > 0) {
|
||||||
|
return visible.map((c) => c.name).join(' › ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (facets && facets[String(catId)]) {
|
||||||
|
const chain = [];
|
||||||
|
let cur = String(catId);
|
||||||
|
const seen = new Set();
|
||||||
|
while (cur && !seen.has(cur) && facets[cur]) {
|
||||||
|
seen.add(cur);
|
||||||
|
const meta = facets[cur];
|
||||||
|
const lower = (meta.name ?? '').trim().toLowerCase();
|
||||||
|
if (
|
||||||
|
cur !== '1' &&
|
||||||
|
(rootCategoryId == null || cur !== String(rootCategoryId)) &&
|
||||||
|
lower !== 'home' &&
|
||||||
|
lower !== 'kategorie 1' &&
|
||||||
|
lower !== 'category 1'
|
||||||
|
) {
|
||||||
|
chain.unshift(meta.name);
|
||||||
|
}
|
||||||
|
cur = meta.parent ? String(meta.parent) : null;
|
||||||
|
}
|
||||||
|
if (chain.length > 0) {
|
||||||
|
return chain.join(' › ');
|
||||||
|
}
|
||||||
|
return facets[String(catId)]?.name ?? `Kategorie ${catId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Kategorie ${catId}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatCategoryList(catStr, facets) {
|
export function formatCategoryList(catStr, facets) {
|
||||||
if (!catStr) return '';
|
if (!catStr) return '';
|
||||||
return catStr
|
return catStr
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ export default function withSearchParams(Wrapped) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const query = queryFromParams(searchParams);
|
const currentTarget = searchParams.get('target');
|
||||||
if (query.target.trim().length >= 3) return;
|
if (currentTarget != null && currentTarget.trim().length > 0) return;
|
||||||
|
|
||||||
const stored = readArtikelQuery();
|
const stored = readArtikelQuery();
|
||||||
if (!stored?.target || stored.target.trim().length < 3) return;
|
if (!stored?.target || stored.target.trim().length < 3) return;
|
||||||
@@ -18,7 +18,7 @@ export default function withSearchParams(Wrapped) {
|
|||||||
buildSearchUrl(stored.target, stored.filter ?? '', stored.offset ?? 0),
|
buildSearchUrl(stored.target, stored.filter ?? '', stored.offset ?? 0),
|
||||||
{ replace: true },
|
{ replace: true },
|
||||||
);
|
);
|
||||||
}, [searchParams, navigate]);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapped
|
<Wrapped
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Alert from '@mui/material/Alert';
|
|||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from '../AppDialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
@@ -238,7 +238,7 @@ export default class BrandImageSearchDialog extends PureComponent {
|
|||||||
error && error.toLowerCase().includes('keys.serper');
|
error && error.toLowerCase().includes('keys.serper');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<AppDialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={processingId ? undefined : onClose}
|
onClose={processingId ? undefined : onClose}
|
||||||
maxWidth="md"
|
maxWidth="md"
|
||||||
@@ -624,7 +624,7 @@ export default class BrandImageSearchDialog extends PureComponent {
|
|||||||
Schließen
|
Schließen
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default function BrandLabel({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: spacing,
|
gap: spacing,
|
||||||
verticalAlign: 'middle',
|
verticalAlign: 'middle',
|
||||||
|
lineHeight: 1,
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
...sx,
|
...sx,
|
||||||
}}
|
}}
|
||||||
@@ -39,6 +40,7 @@ export default function BrandLabel({
|
|||||||
variant="body2"
|
variant="body2"
|
||||||
{...typographyProps}
|
{...typographyProps}
|
||||||
sx={{
|
sx={{
|
||||||
|
lineHeight: 1,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ export default function BrandThumb({
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
verticalAlign: 'middle',
|
verticalAlign: 'middle',
|
||||||
|
lineHeight: 0,
|
||||||
|
my: 0,
|
||||||
|
boxSizing: 'border-box',
|
||||||
bgcolor: TINT_ROW_HOVER,
|
bgcolor: TINT_ROW_HOVER,
|
||||||
boxShadow: SHADOW_THUMB_RING,
|
boxShadow: SHADOW_THUMB_RING,
|
||||||
...sx,
|
...sx,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from '../AppDialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
@@ -119,7 +119,7 @@ class CategoryNameDialog extends Component {
|
|||||||
const canSubmitBulk = bulkNames.length > 0;
|
const canSubmitBulk = bulkNames.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={this.handleClose} maxWidth="xs" fullWidth>
|
<AppDialog open={open} onClose={this.handleClose} maxWidth="xs" fullWidth>
|
||||||
<DialogTitle>{TITLES[mode]}</DialogTitle>
|
<DialogTitle>{TITLES[mode]}</DialogTitle>
|
||||||
|
|
||||||
{!isBulk && (
|
{!isBulk && (
|
||||||
@@ -216,7 +216,7 @@ class CategoryNameDialog extends Component {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import InputAdornment from '@mui/material/InputAdornment';
|
|||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import Chip from '@mui/material/Chip';
|
import Chip from '@mui/material/Chip';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from '../AppDialog';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogContentText from '@mui/material/DialogContentText';
|
import DialogContentText from '@mui/material/DialogContentText';
|
||||||
@@ -663,7 +663,7 @@ export default class CategoryTree extends PureComponent {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Add Dialog */}
|
{/* Add Dialog */}
|
||||||
<Dialog
|
<AppDialog
|
||||||
open={addDialogOpen}
|
open={addDialogOpen}
|
||||||
onClose={() => !isSubmitting && this.setState({ addDialogOpen: false })}
|
onClose={() => !isSubmitting && this.setState({ addDialogOpen: false })}
|
||||||
maxWidth="xs"
|
maxWidth="xs"
|
||||||
@@ -710,10 +710,10 @@ export default class CategoryTree extends PureComponent {
|
|||||||
Anlegen
|
Anlegen
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
|
|
||||||
{/* Rename Dialog */}
|
{/* Rename Dialog */}
|
||||||
<Dialog
|
<AppDialog
|
||||||
open={renameDialogOpen}
|
open={renameDialogOpen}
|
||||||
onClose={() => !isSubmitting && this.setState({ renameDialogOpen: false })}
|
onClose={() => !isSubmitting && this.setState({ renameDialogOpen: false })}
|
||||||
maxWidth="xs"
|
maxWidth="xs"
|
||||||
@@ -753,10 +753,10 @@ export default class CategoryTree extends PureComponent {
|
|||||||
Speichern
|
Speichern
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<Dialog
|
<AppDialog
|
||||||
open={deleteDialogOpen}
|
open={deleteDialogOpen}
|
||||||
onClose={() => !isSubmitting && this.setState({ deleteDialogOpen: false })}
|
onClose={() => !isSubmitting && this.setState({ deleteDialogOpen: false })}
|
||||||
maxWidth="xs"
|
maxWidth="xs"
|
||||||
@@ -788,7 +788,7 @@ export default class CategoryTree extends PureComponent {
|
|||||||
Löschen
|
Löschen
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PureComponent } from 'react';
|
import { PureComponent } from 'react';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from '../AppDialog';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
@@ -67,7 +67,7 @@ export default class NewConversationDialog extends PureComponent {
|
|||||||
const { provider, model, customModel, isCustom, loading, error } = this.state;
|
const { provider, model, customModel, isCustom, loading, error } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<AppDialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={loading ? undefined : onClose}
|
onClose={loading ? undefined : onClose}
|
||||||
maxWidth="xs"
|
maxWidth="xs"
|
||||||
@@ -165,7 +165,7 @@ export default class NewConversationDialog extends PureComponent {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,94 +1,32 @@
|
|||||||
import { PureComponent } from 'react';
|
import { PureComponent } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import AppDialog from '../AppDialog';
|
||||||
import Dialog from '@mui/material/Dialog';
|
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import InputAdornment from '@mui/material/InputAdornment';
|
|
||||||
import List from '@mui/material/List';
|
|
||||||
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
|
||||||
import ListItemButton from '@mui/material/ListItemButton';
|
|
||||||
import ListItemText from '@mui/material/ListItemText';
|
|
||||||
import TextField from '@mui/material/TextField';
|
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import Chip from '@mui/material/Chip';
|
|
||||||
import Alert from '@mui/material/Alert';
|
import Alert from '@mui/material/Alert';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import ArticleSearch from '../article/ArticleSearch';
|
||||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
|
||||||
import LinkIcon from '@mui/icons-material/Link';
|
|
||||||
import articleIndexAutomat from '../../automats/articleIndexAutomat';
|
|
||||||
import ArticlePhoto from '../article/ArticlePhoto';
|
|
||||||
import { BrandLabel } from '../brand';
|
|
||||||
import {
|
|
||||||
BORDER,
|
|
||||||
} from '../../colors';
|
|
||||||
|
|
||||||
export default class ArticleSearchDialog extends PureComponent {
|
export default class ArticleSearchDialog extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
query: '',
|
|
||||||
articles: [],
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
assigningSku: null,
|
assigningSku: null,
|
||||||
|
error: null,
|
||||||
};
|
};
|
||||||
this.handleQueryChange = this.handleQueryChange.bind(this);
|
|
||||||
this.handleClearQuery = this.handleClearQuery.bind(this);
|
|
||||||
this.handleSelect = this.handleSelect.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
if (this.props.open) {
|
|
||||||
this.initFromLine();
|
|
||||||
void this.loadArticles();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
if (!prevProps.open && this.props.open) {
|
if (!prevProps.open && this.props.open) {
|
||||||
this.initFromLine();
|
this.setState({ assigningSku: null, error: null });
|
||||||
void this.loadArticles();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
initFromLine() {
|
handleSelect = async (article) => {
|
||||||
const { line } = this.props;
|
|
||||||
const initialQuery = line?.name?.trim() || line?.sku?.trim() || '';
|
|
||||||
this.setState({
|
|
||||||
query: initialQuery,
|
|
||||||
assigningSku: null,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadArticles() {
|
|
||||||
this.setState({ loading: true, error: null });
|
|
||||||
try {
|
|
||||||
const list = await articleIndexAutomat.actions.ensureLoaded();
|
|
||||||
this.setState({ articles: Array.isArray(list) ? list : [], loading: false });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Fehler beim Laden des Artikel-Index:', err);
|
|
||||||
this.setState({
|
|
||||||
loading: false,
|
|
||||||
error: err?.message || 'Artikel-Index konnte nicht geladen werden.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleQueryChange(e) {
|
|
||||||
this.setState({ query: e.target.value });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClearQuery() {
|
|
||||||
this.setState({ query: '' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleSelect(article) {
|
|
||||||
const { onAssign, line } = this.props;
|
const { onAssign, line } = this.props;
|
||||||
if (!onAssign || !article?.sku) return;
|
if (!onAssign || !article?.sku) return;
|
||||||
|
|
||||||
@@ -106,34 +44,42 @@ export default class ArticleSearchDialog extends PureComponent {
|
|||||||
error: err?.message || 'Fehler beim Verknüpfen des Lieferanten-Artikels.',
|
error: err?.message || 'Fehler beim Verknüpfen des Lieferanten-Artikels.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { open, onClose, line } = this.props;
|
const { open, onClose, line } = this.props;
|
||||||
const { query, articles, loading, error, assigningSku } = this.state;
|
const { assigningSku, error } = this.state;
|
||||||
|
|
||||||
const bookableArticles = articles.filter((a) => !a.isParent && !a.is_parent);
|
|
||||||
const trimmed = query.trim().toLowerCase();
|
|
||||||
const filtered = trimmed
|
|
||||||
? bookableArticles.filter((a) => {
|
|
||||||
const skuMatch = a.sku?.toLowerCase().includes(trimmed);
|
|
||||||
const nameMatch = a.name?.toLowerCase().includes(trimmed);
|
|
||||||
const eanMatch = Array.isArray(a.eans)
|
|
||||||
? a.eans.some((e) => String(e).toLowerCase().includes(trimmed))
|
|
||||||
: false;
|
|
||||||
return skuMatch || nameMatch || eanMatch;
|
|
||||||
})
|
|
||||||
: bookableArticles.slice(0, 50);
|
|
||||||
|
|
||||||
const rawSku = line?.sku != null ? String(line.sku).trim() : '';
|
const rawSku = line?.sku != null ? String(line.sku).trim() : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<AppDialog
|
||||||
open={open}
|
open={Boolean(open)}
|
||||||
onClose={assigningSku ? undefined : onClose}
|
onClose={assigningSku ? undefined : onClose}
|
||||||
maxWidth="sm"
|
maxWidth="md"
|
||||||
fullWidth
|
fullWidth
|
||||||
aria-labelledby="article-search-dialog-title"
|
aria-labelledby="article-search-dialog-title"
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
height: '80vh',
|
||||||
|
minHeight: '80vh',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
sx: {
|
||||||
|
height: '80vh',
|
||||||
|
minHeight: '80vh',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DialogTitle
|
<DialogTitle
|
||||||
id="article-search-dialog-title"
|
id="article-search-dialog-title"
|
||||||
@@ -142,6 +88,7 @@ export default class ArticleSearchDialog extends PureComponent {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
pb: 1,
|
pb: 1,
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
@@ -163,183 +110,31 @@ export default class ArticleSearchDialog extends PureComponent {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogContent dividers sx={{ p: 2 }}>
|
<DialogContent
|
||||||
|
dividers
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
<Alert severity="error" sx={{ mb: 2, flexShrink: 0 }}>
|
||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TextField
|
<ArticleSearch
|
||||||
|
mode="list"
|
||||||
autoFocus
|
autoFocus
|
||||||
fullWidth
|
allowParent={false}
|
||||||
size="small"
|
busySku={assigningSku}
|
||||||
placeholder="Artikel nach SKU, Name oder EAN suchen…"
|
actionLabel={assigningSku ? 'Zuordnen…' : 'Zuordnen'}
|
||||||
value={query}
|
onSelect={this.handleSelect}
|
||||||
onChange={this.handleQueryChange}
|
|
||||||
disabled={Boolean(assigningSku)}
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">
|
|
||||||
<SearchIcon fontSize="small" color="action" />
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
endAdornment: query ? (
|
|
||||||
<InputAdornment position="end">
|
|
||||||
<IconButton size="small" onClick={this.handleClearQuery} edge="end">
|
|
||||||
<CloseIcon sx={{ fontSize: 16 }} />
|
|
||||||
</IconButton>
|
|
||||||
</InputAdornment>
|
|
||||||
) : null,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
sx={{ mb: 1.5 }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', py: 6, gap: 1.5 }}>
|
|
||||||
<CircularProgress size={20} />
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Artikel werden geladen…
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : filtered.length === 0 ? (
|
|
||||||
<Box sx={{ textAlign: 'center', py: 5 }}>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Keine Artikel für „{query}“ gefunden.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<List
|
|
||||||
dense
|
|
||||||
disablePadding
|
|
||||||
sx={{
|
|
||||||
maxHeight: 380,
|
|
||||||
overflowY: 'auto',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
borderRadius: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{filtered.map((article) => {
|
|
||||||
const photo = article.photos?.[0] || article.articlepic?.[0];
|
|
||||||
const fullHash = photo?.hash || photo?.fullHash;
|
|
||||||
const thumbHash = photo?.thumbhash || photo?.thumbHash;
|
|
||||||
const thumbW = photo?.thumbw || photo?.thumbW;
|
|
||||||
const thumbH = photo?.thumbh || photo?.thumbH;
|
|
||||||
const hasPhoto = fullHash != null || thumbHash != null;
|
|
||||||
const primaryEan = Array.isArray(article.eans) ? article.eans[0] : article.eans;
|
|
||||||
const isAssigning = assigningSku === article.sku;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ListItemButton
|
|
||||||
key={article.id || article.sku}
|
|
||||||
divider
|
|
||||||
disabled={Boolean(assigningSku)}
|
|
||||||
onClick={() => this.handleSelect(article)}
|
|
||||||
sx={{
|
|
||||||
py: 1,
|
|
||||||
px: 1.5,
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'action.hover',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListItemAvatar sx={{ minWidth: 44 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 34,
|
|
||||||
height: 34,
|
|
||||||
borderRadius: 0.75,
|
|
||||||
overflow: 'hidden',
|
|
||||||
bgcolor: BORDER,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hasPhoto ? (
|
|
||||||
<ArticlePhoto
|
|
||||||
photo={{ fullHash, thumbHash, thumbW, thumbH }}
|
|
||||||
size={34}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Inventory2OutlinedIcon sx={{ fontSize: 18, color: 'text.secondary' }} />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</ListItemAvatar>
|
|
||||||
<ListItemText
|
|
||||||
primary={
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={article.sku}
|
|
||||||
sx={{
|
|
||||||
height: 18,
|
|
||||||
fontSize: '0.68rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
bgcolor: 'primary.50',
|
|
||||||
color: 'primary.dark',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Typography variant="body2" fontWeight={600} noWrap sx={{ maxWidth: 300 }}>
|
|
||||||
{article.name || '–'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
secondary={
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.25 }}>
|
|
||||||
{(article.brand?.name || article.brandName) && (
|
|
||||||
<BrandLabel
|
|
||||||
brand={article.brand || { name: article.brandName, thumbHash: article.brandThumbHash }}
|
|
||||||
name={article.brand?.name || article.brandName}
|
|
||||||
size={14}
|
|
||||||
typographyProps={{
|
|
||||||
variant: 'caption',
|
|
||||||
color: 'text.secondary',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{primaryEan ? (
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ fontFamily: 'monospace', fontSize: '0.7rem' }}
|
|
||||||
>
|
|
||||||
EAN: {primaryEan}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={isAssigning ? <CircularProgress size={12} /> : <LinkIcon sx={{ fontSize: 14 }} />}
|
|
||||||
disabled={Boolean(assigningSku)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
void this.handleSelect(article);
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
fontSize: '0.72rem',
|
|
||||||
textTransform: 'none',
|
|
||||||
py: 0.25,
|
|
||||||
px: 1.25,
|
|
||||||
ml: 1,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isAssigning ? 'Zuordnen…' : 'Zuordnen'}
|
|
||||||
</Button>
|
|
||||||
</ListItemButton>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</List>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions sx={{ px: 2, py: 1.5 }}>
|
<DialogActions sx={{ px: 2, py: 1.5 }}>
|
||||||
@@ -351,7 +146,7 @@ export default class ArticleSearchDialog extends PureComponent {
|
|||||||
Abbrechen
|
Abbrechen
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { PureComponent } from 'react';
|
|||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from '../AppDialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
@@ -507,7 +507,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
const isWarehouseMissing = !selectedWarehouseId;
|
const isWarehouseMissing = !selectedWarehouseId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<AppDialog
|
||||||
open={Boolean(open)}
|
open={Boolean(open)}
|
||||||
onClose={isBooking ? undefined : onClose}
|
onClose={isBooking ? undefined : onClose}
|
||||||
maxWidth="md"
|
maxWidth="md"
|
||||||
@@ -692,7 +692,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import withNavigate from '../withNavigate';
|
|||||||
import ManifestHeader from './ManifestHeader';
|
import ManifestHeader from './ManifestHeader';
|
||||||
import ManifestLines from './ManifestLines';
|
import ManifestLines from './ManifestLines';
|
||||||
import ManifestPdfDialog from './ManifestPdfDialog';
|
import ManifestPdfDialog from './ManifestPdfDialog';
|
||||||
|
import ArticleSearchDialog from './ArticleSearchDialog';
|
||||||
import {
|
import {
|
||||||
buildManifestBody,
|
buildManifestBody,
|
||||||
restoreAttachmentsFromApi,
|
restoreAttachmentsFromApi,
|
||||||
@@ -43,6 +44,8 @@ class ManifestForm extends PureComponent {
|
|||||||
pdfDialogOpen: false,
|
pdfDialogOpen: false,
|
||||||
pdfDialogData: null,
|
pdfDialogData: null,
|
||||||
pdfFileName: '',
|
pdfFileName: '',
|
||||||
|
articleSearchDialogOpen: false,
|
||||||
|
articleSearchLine: null,
|
||||||
};
|
};
|
||||||
this._cancelled = false;
|
this._cancelled = false;
|
||||||
this.handleRetry = this.handleRetry.bind(this);
|
this.handleRetry = this.handleRetry.bind(this);
|
||||||
@@ -53,6 +56,10 @@ class ManifestForm extends PureComponent {
|
|||||||
this.handleExternalNumberChange.bind(this);
|
this.handleExternalNumberChange.bind(this);
|
||||||
this.handleLineChange = this.handleLineChange.bind(this);
|
this.handleLineChange = this.handleLineChange.bind(this);
|
||||||
this.handleAddLine = this.handleAddLine.bind(this);
|
this.handleAddLine = this.handleAddLine.bind(this);
|
||||||
|
this.handleAddArticleLine = this.handleAddArticleLine.bind(this);
|
||||||
|
this.handleOpenArticleSearchForLine = this.handleOpenArticleSearchForLine.bind(this);
|
||||||
|
this.handleCloseArticleSearch = this.handleCloseArticleSearch.bind(this);
|
||||||
|
this.handleAssignArticleForLine = this.handleAssignArticleForLine.bind(this);
|
||||||
this.handleRemoveLine = this.handleRemoveLine.bind(this);
|
this.handleRemoveLine = this.handleRemoveLine.bind(this);
|
||||||
this.handleRemoveAllLines = this.handleRemoveAllLines.bind(this);
|
this.handleRemoveAllLines = this.handleRemoveAllLines.bind(this);
|
||||||
this.handleSave = this.handleSave.bind(this);
|
this.handleSave = this.handleSave.bind(this);
|
||||||
@@ -205,6 +212,95 @@ class ManifestForm extends PureComponent {
|
|||||||
this.setState((prev) => ({ lines: [...prev.lines, emptyLine()] }));
|
this.setState((prev) => ({ lines: [...prev.lines, emptyLine()] }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleAddArticleLine(article) {
|
||||||
|
if (!article) return;
|
||||||
|
const netVal =
|
||||||
|
article.price != null
|
||||||
|
? String(article.price)
|
||||||
|
: article.vknetto != null
|
||||||
|
? String(article.vknetto)
|
||||||
|
: article.net != null
|
||||||
|
? String(article.net)
|
||||||
|
: '';
|
||||||
|
const taxVal =
|
||||||
|
article.taxrate != null
|
||||||
|
? String(article.taxrate)
|
||||||
|
: article.tax != null
|
||||||
|
? String(article.tax)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const newLine = {
|
||||||
|
key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
amount: '1',
|
||||||
|
sku: article.sku || '',
|
||||||
|
name: article.name || '',
|
||||||
|
tax: taxVal,
|
||||||
|
taxrate: taxVal,
|
||||||
|
net: netVal,
|
||||||
|
netprice: netVal,
|
||||||
|
rebate: '',
|
||||||
|
pu: '1',
|
||||||
|
unit: article.unit || 'Stück',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setState((prev) => {
|
||||||
|
if (prev.lines.length === 1 && !prev.lines[0].sku && !prev.lines[0].name) {
|
||||||
|
return { lines: [newLine] };
|
||||||
|
}
|
||||||
|
return { lines: [...prev.lines, newLine] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOpenArticleSearchForLine(line) {
|
||||||
|
this.setState({
|
||||||
|
articleSearchDialogOpen: true,
|
||||||
|
articleSearchLine: line,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCloseArticleSearch() {
|
||||||
|
this.setState({
|
||||||
|
articleSearchDialogOpen: false,
|
||||||
|
articleSearchLine: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAssignArticleForLine(article, targetLine) {
|
||||||
|
if (!article || !targetLine) return;
|
||||||
|
const netVal =
|
||||||
|
article.price != null
|
||||||
|
? String(article.price)
|
||||||
|
: article.vknetto != null
|
||||||
|
? String(article.vknetto)
|
||||||
|
: article.net != null
|
||||||
|
? String(article.net)
|
||||||
|
: '';
|
||||||
|
const taxVal =
|
||||||
|
article.taxrate != null
|
||||||
|
? String(article.taxrate)
|
||||||
|
: article.tax != null
|
||||||
|
? String(article.tax)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
this.setState((prev) => ({
|
||||||
|
lines: prev.lines.map((l) => {
|
||||||
|
if (l.key !== targetLine.key) return l;
|
||||||
|
return {
|
||||||
|
...l,
|
||||||
|
sku: article.sku || l.sku,
|
||||||
|
name: article.name || l.name,
|
||||||
|
tax: taxVal || l.tax,
|
||||||
|
taxrate: taxVal || l.taxrate,
|
||||||
|
net: netVal || l.net,
|
||||||
|
netprice: netVal || l.netprice,
|
||||||
|
unit: article.unit || l.unit || 'Stück',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
articleSearchDialogOpen: false,
|
||||||
|
articleSearchLine: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
handleRemoveLine(key) {
|
handleRemoveLine(key) {
|
||||||
this.setState((prev) => {
|
this.setState((prev) => {
|
||||||
const next = prev.lines.filter((line) => line.key !== key);
|
const next = prev.lines.filter((line) => line.key !== key);
|
||||||
@@ -424,6 +520,8 @@ class ManifestForm extends PureComponent {
|
|||||||
linesColor={cfg.linesColor}
|
linesColor={cfg.linesColor}
|
||||||
onLineChange={this.handleLineChange}
|
onLineChange={this.handleLineChange}
|
||||||
onAddLine={this.handleAddLine}
|
onAddLine={this.handleAddLine}
|
||||||
|
onAddArticleLine={this.handleAddArticleLine}
|
||||||
|
onSearchArticle={this.handleOpenArticleSearchForLine}
|
||||||
onRemoveLine={this.handleRemoveLine}
|
onRemoveLine={this.handleRemoveLine}
|
||||||
onRemoveAllLines={this.handleRemoveAllLines}
|
onRemoveAllLines={this.handleRemoveAllLines}
|
||||||
onSave={() => void this.handleSave()}
|
onSave={() => void this.handleSave()}
|
||||||
@@ -496,6 +594,12 @@ class ManifestForm extends PureComponent {
|
|||||||
onClose={this.handleClosePdfDialog}
|
onClose={this.handleClosePdfDialog}
|
||||||
onAbort={this.handleAbortParse}
|
onAbort={this.handleAbortParse}
|
||||||
/>
|
/>
|
||||||
|
<ArticleSearchDialog
|
||||||
|
open={this.state.articleSearchDialogOpen}
|
||||||
|
line={this.state.articleSearchLine}
|
||||||
|
onClose={this.handleCloseArticleSearch}
|
||||||
|
onAssign={this.handleAssignArticleForLine}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import DeleteSweepOutlinedIcon from '@mui/icons-material/DeleteSweepOutlined';
|
|||||||
import { PanelTile } from 'dashgui/panels';
|
import { PanelTile } from 'dashgui/panels';
|
||||||
import { LINE_COLUMNS, hasArticleLines } from './manifestConfig';
|
import { LINE_COLUMNS, hasArticleLines } from './manifestConfig';
|
||||||
import ManifestFormLine from './ManifestFormLine';
|
import ManifestFormLine from './ManifestFormLine';
|
||||||
|
import ArticleSearch from '../article/ArticleSearch';
|
||||||
|
|
||||||
class ManifestLines extends PureComponent {
|
class ManifestLines extends PureComponent {
|
||||||
render() {
|
render() {
|
||||||
@@ -22,6 +23,7 @@ class ManifestLines extends PureComponent {
|
|||||||
linesColor,
|
linesColor,
|
||||||
onLineChange,
|
onLineChange,
|
||||||
onAddLine,
|
onAddLine,
|
||||||
|
onAddArticleLine,
|
||||||
onRemoveLine,
|
onRemoveLine,
|
||||||
onRemoveAllLines,
|
onRemoveAllLines,
|
||||||
columns = LINE_COLUMNS,
|
columns = LINE_COLUMNS,
|
||||||
@@ -117,14 +119,25 @@ class ManifestLines extends PureComponent {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
{onAddArticleLine && (
|
||||||
|
<ArticleSearch
|
||||||
|
size="small"
|
||||||
|
clearOnSelect
|
||||||
|
allowParent={false}
|
||||||
|
placeholder="Artikel suchen und hinzufügen…"
|
||||||
|
disabled={saving}
|
||||||
|
onSelect={onAddArticleLine}
|
||||||
|
sx={{ minWidth: 260, flex: 1, maxWidth: 440 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
startIcon={<AddIcon />}
|
startIcon={<AddIcon />}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onClick={onAddLine}
|
onClick={onAddLine}
|
||||||
>
|
>
|
||||||
Zeile hinzufügen
|
{onAddArticleLine ? 'Leere Zeile' : 'Zeile hinzufügen'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Button from '@mui/material/Button';
|
|||||||
import Chip from '@mui/material/Chip';
|
import Chip from '@mui/material/Chip';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import Collapse from '@mui/material/Collapse';
|
import Collapse from '@mui/material/Collapse';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from '../AppDialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
@@ -113,7 +113,7 @@ export default function ManifestPdfDialog({
|
|||||||
const handleAbortOrClose = streaming ? (onAbort || onClose) : onClose;
|
const handleAbortOrClose = streaming ? (onAbort || onClose) : onClose;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<AppDialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={handleAbortOrClose}
|
onClose={handleAbortOrClose}
|
||||||
maxWidth="sm"
|
maxWidth="sm"
|
||||||
@@ -244,6 +244,6 @@ export default function ManifestPdfDialog({
|
|||||||
)}
|
)}
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
|
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Alert from '@mui/material/Alert';
|
|||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from '../AppDialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
@@ -110,7 +110,7 @@ class PrinterFormDialog extends Component {
|
|||||||
const { open, onClose, title } = this.props;
|
const { open, onClose, title } = this.props;
|
||||||
const { form, busy, error } = this.state;
|
const { form, busy, error } = this.state;
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={busy ? undefined : onClose} maxWidth="sm" fullWidth>
|
<AppDialog open={open} onClose={busy ? undefined : onClose} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
@@ -180,7 +180,7 @@ class PrinterFormDialog extends Component {
|
|||||||
{busy ? <CircularProgress size={18} /> : 'Speichern'}
|
{busy ? <CircularProgress size={18} /> : 'Speichern'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from '../AppDialog';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import Tab from '@mui/material/Tab';
|
import Tab from '@mui/material/Tab';
|
||||||
@@ -47,7 +47,7 @@ export default class SettingsDialog extends Component {
|
|||||||
const { tab } = this.state;
|
const { tab } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
<AppDialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||||
<DialogTitle>Einstellungen</DialogTitle>
|
<DialogTitle>Einstellungen</DialogTitle>
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
@@ -74,7 +74,7 @@ export default class SettingsDialog extends Component {
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,6 +43,16 @@ html {
|
|||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* While any AppDialog is open, the reserved scrollbar-gutter strip must read
|
||||||
|
as fog, not app background. AppDialog toggles `df-dialog-open` on <html>
|
||||||
|
(ref-counted for stacked dialogs). The solid fog token is published by
|
||||||
|
src/colors.js (installCssVars) with a hard-coded fallback. */
|
||||||
|
html.df-dialog-open,
|
||||||
|
html.df-dialog-open body,
|
||||||
|
html.df-dialog-open #root {
|
||||||
|
background-color: var(--df-dialog-fog, #152e73);
|
||||||
|
}
|
||||||
|
|
||||||
/* SortableJS: Kategorie-Reorder im Edit-Modus.
|
/* SortableJS: Kategorie-Reorder im Edit-Modus.
|
||||||
Colors come from CSS custom properties injected by src/colors.js
|
Colors come from CSS custom properties injected by src/colors.js
|
||||||
(installCssVars), so this file stays in sync with the JS token module.
|
(installCssVars), so this file stays in sync with the JS token module.
|
||||||
|
|||||||
13
src/main.jsx
13
src/main.jsx
@@ -37,6 +37,19 @@ if (import.meta.env.DEV) {
|
|||||||
void clearDevServiceWorker();
|
void clearDevServiceWorker();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PWA auto-update: reload the page as soon as a new build's service worker
|
||||||
|
// takes control. The virtual module only exists in production builds (the
|
||||||
|
// plugin is registered for `build` only), hence the dynamic import.
|
||||||
|
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
|
||||||
|
import('virtual:pwa-register')
|
||||||
|
.then(({ registerSW }) => {
|
||||||
|
registerSW({ immediate: true });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// No SW in this build — app still works, just without offline support.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<ThemeProvider theme={getActiveTheme()}>
|
<ThemeProvider theme={getActiveTheme()}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Component, createRef } from 'react';
|
import { Component, createRef } from 'react';
|
||||||
import FillSpace from 'dashgui/FillSpace';
|
import FillSpace from 'dashgui/FillSpace';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
import InputAdornment from '@mui/material/InputAdornment';
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
import List from '@mui/material/List';
|
import List from '@mui/material/List';
|
||||||
import ListItemButton from '@mui/material/ListItemButton';
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
@@ -10,6 +13,10 @@ import TextField from '@mui/material/TextField';
|
|||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import ClearIcon from '@mui/icons-material/Clear';
|
import ClearIcon from '@mui/icons-material/Clear';
|
||||||
|
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||||
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||||
import articleSearchAutomat from '../automats/articleSearchAutomat';
|
import articleSearchAutomat from '../automats/articleSearchAutomat';
|
||||||
import BackendError from '../components/BackendError';
|
import BackendError from '../components/BackendError';
|
||||||
import { formatPrice } from '../utils/price';
|
import { formatPrice } from '../utils/price';
|
||||||
@@ -25,43 +32,76 @@ import {
|
|||||||
formatCategoryList,
|
formatCategoryList,
|
||||||
formatStock,
|
formatStock,
|
||||||
getCategoryDepth,
|
getCategoryDepth,
|
||||||
|
getCategoryBreadcrumbLabel,
|
||||||
parseFilter,
|
parseFilter,
|
||||||
} from '../components/article/searchFormat';
|
} from '../components/article/searchFormat';
|
||||||
import { preserveSearchFocus } from '../components/article/searchFocus';
|
import { preserveSearchFocus } from '../components/article/searchFocus';
|
||||||
import { artikelDetailPath } from '../components/article/articleRoute';
|
import { artikelDetailPath } from '../components/article/articleRoute';
|
||||||
|
import { buildCategoryPath } from '../components/category/categoryRoute';
|
||||||
import withSearchParams from '../components/article/withSearchParams';
|
import withSearchParams from '../components/article/withSearchParams';
|
||||||
import { setLastArticleSku } from '../automats';
|
|
||||||
import { BrandLabel } from '../components/brand';
|
|
||||||
import {
|
import {
|
||||||
BLUE_BG,
|
setLastArticleSku,
|
||||||
} from '../colors';
|
setLastSearchTerm,
|
||||||
|
setLastCategoryId,
|
||||||
|
navigationAutomat,
|
||||||
|
categoryAutomat,
|
||||||
|
} from '../automats';
|
||||||
|
import { BrandLabel } from '../components/brand';
|
||||||
|
|
||||||
const resultRowSx = {
|
const resultRowSx = {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
py: 1.5,
|
minHeight: 64,
|
||||||
|
maxHeight: 64,
|
||||||
|
height: 64,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
py: 0,
|
||||||
// Keep every row one line so sample height matches real rows.
|
// Keep every row one line so sample height matches real rows.
|
||||||
'& .MuiListItemText-root': { minWidth: 0 },
|
'& .MuiListItemText-root': { minWidth: 0, my: 0 },
|
||||||
'& .MuiListItemText-primary, & .MuiListItemText-secondary': {
|
'& .MuiListItemText-primary': {
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
|
lineHeight: '22px',
|
||||||
|
height: '22px',
|
||||||
|
},
|
||||||
|
'& .MuiListItemText-secondary': {
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
lineHeight: '20px',
|
||||||
|
height: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function ResultRowMeasureSample() {
|
function ResultRowMeasureSample() {
|
||||||
return (
|
return (
|
||||||
<ListItemButton tabIndex={-1} divider sx={resultRowSx}>
|
<ListItemButton
|
||||||
|
tabIndex={-1}
|
||||||
|
divider
|
||||||
|
disabled
|
||||||
|
sx={{
|
||||||
|
...resultRowSx,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
opacity: 0.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary="Measure"
|
primary={'\u00A0'}
|
||||||
secondary={
|
secondary={
|
||||||
<Typography component="span" variant="body2" color="text.secondary" noWrap>
|
<Typography component="span" variant="body2" sx={{ visibility: 'hidden', lineHeight: '20px', height: '20px' }} noWrap>
|
||||||
SKU · Bestand 0 · Verfügbar 0 · Kategorie
|
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
|
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
|
||||||
/>
|
/>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, flexShrink: 0, ml: 2 }}>
|
<Typography
|
||||||
0,00 €
|
variant="body2"
|
||||||
|
sx={{ fontWeight: 600, flexShrink: 0, ml: 2, visibility: 'hidden' }}
|
||||||
|
>
|
||||||
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
);
|
);
|
||||||
@@ -70,97 +110,80 @@ function ResultRowMeasureSample() {
|
|||||||
function initialSearchState(searchParams) {
|
function initialSearchState(searchParams) {
|
||||||
const query = queryFromParams(searchParams);
|
const query = queryFromParams(searchParams);
|
||||||
const stored = readArtikelQuery();
|
const stored = readArtikelQuery();
|
||||||
|
const navTerm = navigationAutomat.state.lastSearchTerm;
|
||||||
const input =
|
const input =
|
||||||
query.target.trim().length >= 3
|
query.target.trim().length > 0
|
||||||
? query.target
|
? query.target
|
||||||
: (stored?.target?.trim().length >= 3 ? stored.target : query.target);
|
: (navTerm?.trim().length > 0
|
||||||
|
? navTerm
|
||||||
|
: (stored?.target?.trim().length >= 3 ? stored.target : ''));
|
||||||
|
|
||||||
|
const automatResult = articleSearchAutomat.state.result;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
input,
|
input,
|
||||||
error: null,
|
categoryFilterOpen: false,
|
||||||
categoryFacets: null,
|
error: automatResult.error,
|
||||||
totalCount: null,
|
categoryFacets: automatResult.categoryFacets,
|
||||||
recordset: null,
|
totalCount: automatResult.totalCount,
|
||||||
pageSize: null,
|
recordset: automatResult.recordset,
|
||||||
|
pageSize: automatResult.pageSize,
|
||||||
|
pageCount: automatResult.pageCount,
|
||||||
|
currentPage: automatResult.currentPage,
|
||||||
|
offset: automatResult.offset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCategoryTreeOrder(categoriesMap, rootCategoryId) {
|
||||||
|
const order = new Map();
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
function traverse(parentId) {
|
||||||
|
const parentKey = String(parentId);
|
||||||
|
const children = Object.values(categoriesMap || {})
|
||||||
|
.filter((item) => item && item.parentId != null && String(item.parentId) === parentKey)
|
||||||
|
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || Number(a.id) - Number(b.id));
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
order.set(String(child.id), index++);
|
||||||
|
traverse(child.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootId = rootCategoryId ?? 1;
|
||||||
|
traverse(rootId);
|
||||||
|
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
class ArtikelSearchPage extends Component {
|
class ArtikelSearchPage extends Component {
|
||||||
state = initialSearchState(this.props.searchParams);
|
state = initialSearchState(this.props.searchParams);
|
||||||
listSlotRef = { current: null };
|
|
||||||
rowMeasureRef = { current: null };
|
|
||||||
paginationRef = createRef();
|
paginationRef = createRef();
|
||||||
|
filterHeaderRef = createRef();
|
||||||
|
fillSpaceRef = createRef();
|
||||||
|
categoryFillSpaceRef = createRef();
|
||||||
|
|
||||||
setListSlotRef = (node) => {
|
toggleCategoryFilter = () => {
|
||||||
if (this.listSlotRef.current === node) return;
|
this.setState((prev) => ({ categoryFilterOpen: !prev.categoryFilterOpen }));
|
||||||
this.listSlotRef.current = node;
|
|
||||||
if (!node) return;
|
|
||||||
this.updatePageSize();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setRowMeasureRef = (node) => {
|
handleMeasurement = (count) => {
|
||||||
if (this.rowMeasureRef.current === node) return;
|
if (!count || count === this.state.pageSize) return;
|
||||||
this.rowMeasureRef.current = node;
|
this.setState({ pageSize: count });
|
||||||
if (!node) return;
|
const { filter, offset } = queryFromParams(this.props.searchParams);
|
||||||
this.updatePageSize();
|
const activeTarget = this.state.input;
|
||||||
};
|
setLastSearchTerm(activeTarget, buildSearchUrl(activeTarget, filter, offset));
|
||||||
|
void articleSearchAutomat.actions.search({
|
||||||
measureRowHeight = () => {
|
target: activeTarget,
|
||||||
// Always use the sample — real rows used to report a different height after paint
|
filter,
|
||||||
// (wrapped text), which made resize "fix" a wrong first measure.
|
offset,
|
||||||
const sample = this.rowMeasureRef.current;
|
pageSize: count,
|
||||||
if (!sample) return { height: 0, source: 'none' };
|
}).catch(() => {});
|
||||||
return {
|
|
||||||
height: sample.getBoundingClientRect().height,
|
|
||||||
source: 'sample',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
updatePageSize = ({ fromResize = false } = {}) => {
|
|
||||||
const slot = this.listSlotRef.current;
|
|
||||||
if (!slot) return;
|
|
||||||
|
|
||||||
// After the first measure, ignore layout churn from painting results.
|
|
||||||
// Only window resize may change pageSize and refetch.
|
|
||||||
if (this.state.pageSize != null && !fromResize) return;
|
|
||||||
|
|
||||||
const slotTop = slot.getBoundingClientRect().top;
|
|
||||||
const available = window.innerHeight - slotTop;
|
|
||||||
const { height: rowHeight, source: rowSource } = this.measureRowHeight();
|
|
||||||
if (available < 1 || rowHeight < 1) return;
|
|
||||||
|
|
||||||
// Outlined Paper around the list is 1px top + 1px bottom — not in row height.
|
|
||||||
const listChrome = 2;
|
|
||||||
const next = Math.max(1, Math.floor((available - listChrome) / rowHeight));
|
|
||||||
|
|
||||||
console.log('[pageSize] measure', {
|
|
||||||
fromResize,
|
|
||||||
prevPageSize: this.state.pageSize,
|
|
||||||
next,
|
|
||||||
available,
|
|
||||||
rowHeight,
|
|
||||||
rowSource,
|
|
||||||
listChrome,
|
|
||||||
slotTop,
|
|
||||||
realRowHeight:
|
|
||||||
slot.querySelector('[data-result-row]')?.getBoundingClientRect().height ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (next === this.state.pageSize) return;
|
|
||||||
|
|
||||||
this.setState({ pageSize: next }, () => {
|
|
||||||
const target = this.props.searchParams.get('target') ?? '';
|
|
||||||
if (target) this.fetchResults();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
handleWindowResize = () => {
|
|
||||||
this.updatePageSize({ fromResize: true });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
persistQuery = () => {
|
persistQuery = () => {
|
||||||
const query = queryFromParams(this.props.searchParams);
|
const query = queryFromParams(this.props.searchParams);
|
||||||
const target = query.target || this.state.input;
|
const target = this.state.input;
|
||||||
writeArtikelQuery({
|
writeArtikelQuery({
|
||||||
target,
|
target,
|
||||||
filter: query.filter,
|
filter: query.filter,
|
||||||
@@ -170,37 +193,71 @@ class ArtikelSearchPage extends Component {
|
|||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
document.addEventListener('mouseup', preserveSearchFocus, true);
|
document.addEventListener('mouseup', preserveSearchFocus, true);
|
||||||
window.addEventListener('resize', this.handleWindowResize);
|
|
||||||
setLastArticleSku(null);
|
setLastArticleSku(null);
|
||||||
|
|
||||||
// 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);
|
||||||
|
|
||||||
// Kick off initial search (empty input = show all products).
|
// Initial search: kicks off index loading if idle and derives current items
|
||||||
void articleSearchAutomat.actions.search(this.state.input).catch(() => {});
|
const { filter: urlFilter, offset } = queryFromParams(this.props.searchParams);
|
||||||
|
const hasFilterInUrl = this.props.searchParams.has('filter');
|
||||||
|
const navCategory = navigationAutomat.state.lastCategoryId;
|
||||||
|
const filter = hasFilterInUrl ? urlFilter : (navCategory || urlFilter);
|
||||||
|
const activeTarget = this.state.input;
|
||||||
|
|
||||||
|
if (!hasFilterInUrl && navCategory) {
|
||||||
|
const syncedUrl = buildSearchUrl(activeTarget, filter, offset);
|
||||||
|
setLastSearchTerm(activeTarget, syncedUrl);
|
||||||
|
this.props.navigate(syncedUrl, { replace: true });
|
||||||
|
} else if (hasFilterInUrl) {
|
||||||
|
const selectedCats = parseFilter(urlFilter);
|
||||||
|
const primaryCatId = selectedCats.length > 0 ? selectedCats[0] : null;
|
||||||
|
setLastCategoryId(primaryCatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastSearchTerm(activeTarget, buildSearchUrl(activeTarget, filter, offset));
|
||||||
|
void articleSearchAutomat.actions.search({
|
||||||
|
target: activeTarget,
|
||||||
|
filter,
|
||||||
|
offset,
|
||||||
|
pageSize: this.state.pageSize,
|
||||||
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
document.removeEventListener('mouseup', preserveSearchFocus, true);
|
document.removeEventListener('mouseup', preserveSearchFocus, true);
|
||||||
window.removeEventListener('resize', this.handleWindowResize);
|
|
||||||
this.unsubSearch?.();
|
this.unsubSearch?.();
|
||||||
this.unsubSearch = null;
|
this.unsubSearch = null;
|
||||||
this.persistQuery();
|
this.persistQuery();
|
||||||
|
const query = queryFromParams(this.props.searchParams);
|
||||||
|
const target = this.state.input;
|
||||||
|
setLastSearchTerm(target, buildSearchUrl(target, query.filter, query.offset));
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps, prevState) {
|
||||||
const { searchParams } = this.props;
|
const { searchParams } = this.props;
|
||||||
const prev = prevProps.searchParams;
|
const prev = prevProps.searchParams;
|
||||||
const target = searchParams.get('target') ?? '';
|
const target = searchParams.get('target') ?? '';
|
||||||
const prevTarget = prev.get('target') ?? '';
|
const prevTarget = prev.get('target') ?? '';
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.state.categoryFilterOpen !== prevState?.categoryFilterOpen ||
|
||||||
|
searchParams.get('filter') !== prev.get('filter')
|
||||||
|
) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.fillSpaceRef.current?.measure();
|
||||||
|
this.categoryFillSpaceRef.current?.measure();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (target !== prevTarget && target !== this.state.input) {
|
if (target !== prevTarget && target !== this.state.input) {
|
||||||
this.setState({ input: target });
|
this.setState({ input: target });
|
||||||
|
this.fetchResults();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
target !== prevTarget
|
searchParams.get('filter') !== prev.get('filter')
|
||||||
|| searchParams.get('filter') !== prev.get('filter')
|
|
||||||
|| searchParams.get('offset') !== prev.get('offset')
|
|| searchParams.get('offset') !== prev.get('offset')
|
||||||
) {
|
) {
|
||||||
this.fetchResults();
|
this.fetchResults();
|
||||||
@@ -213,7 +270,23 @@ class ArtikelSearchPage extends Component {
|
|||||||
filter: nextFilter,
|
filter: nextFilter,
|
||||||
offset: nextOffset,
|
offset: nextOffset,
|
||||||
});
|
});
|
||||||
this.props.navigate(buildSearchUrl(nextTarget, nextFilter, nextOffset), {
|
const nextUrl = buildSearchUrl(nextTarget, nextFilter, nextOffset);
|
||||||
|
setLastSearchTerm(nextTarget, nextUrl);
|
||||||
|
|
||||||
|
// Synchronize category with navigationAutomat and /kategorien/
|
||||||
|
const selectedCats = parseFilter(nextFilter);
|
||||||
|
const primaryCatId = selectedCats.length > 0 ? selectedCats[0] : null;
|
||||||
|
const categoriesMap = categoryAutomat.state?.categories;
|
||||||
|
const rootCategoryId = categoryAutomat.state?.rootCategoryId;
|
||||||
|
const isRoot = !primaryCatId || (rootCategoryId != null && String(primaryCatId) === String(rootCategoryId));
|
||||||
|
const activeId = isRoot ? null : String(primaryCatId);
|
||||||
|
const catPath = activeId && categoriesMap && rootCategoryId != null
|
||||||
|
? buildCategoryPath(categoriesMap, rootCategoryId, activeId)
|
||||||
|
: '/kategorien';
|
||||||
|
|
||||||
|
setLastCategoryId(activeId, catPath);
|
||||||
|
|
||||||
|
this.props.navigate(nextUrl, {
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -221,56 +294,110 @@ 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 });
|
||||||
void articleSearchAutomat.actions.search(value).catch(() => {});
|
const { filter } = queryFromParams(this.props.searchParams);
|
||||||
|
const { pageSize } = this.state;
|
||||||
|
const currentUrl = buildSearchUrl(value, filter, 0);
|
||||||
|
setLastSearchTerm(value, currentUrl);
|
||||||
|
writeArtikelQuery({ target: value, filter, offset: 0 });
|
||||||
|
this.props.navigate(currentUrl, { replace: true });
|
||||||
|
void articleSearchAutomat.actions.search({
|
||||||
|
target: value,
|
||||||
|
filter,
|
||||||
|
offset: 0,
|
||||||
|
pageSize: pageSize ?? undefined,
|
||||||
|
}).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchResults = () => {
|
fetchResults = () => {
|
||||||
const { target, filter, offset } = queryFromParams(this.props.searchParams);
|
const { target, filter, offset } = queryFromParams(this.props.searchParams);
|
||||||
const { pageSize } = this.state;
|
const { pageSize } = this.state;
|
||||||
|
|
||||||
if (pageSize == null) return;
|
|
||||||
|
|
||||||
writeArtikelQuery({ target, filter, offset });
|
writeArtikelQuery({ target, filter, offset });
|
||||||
|
|
||||||
void articleSearchAutomat.actions.search(target.trim()).catch(() => {});
|
void articleSearchAutomat.actions.search({
|
||||||
|
target: target.trim(),
|
||||||
|
filter,
|
||||||
|
offset,
|
||||||
|
pageSize: pageSize ?? undefined,
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
handlePageChange = (_e, page) => {
|
||||||
|
const { pageSize } = this.state;
|
||||||
|
const safePageSize = pageSize ?? 1;
|
||||||
|
const nextOffset = (page - 1) * safePageSize;
|
||||||
|
const filterParam = this.props.searchParams.get('filter') ?? '';
|
||||||
|
this.updateParams(this.state.input, filterParam, nextOffset);
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { input, error, categoryFacets, totalCount, recordset, pageSize } = this.state;
|
const {
|
||||||
|
input,
|
||||||
|
categoryFilterOpen,
|
||||||
|
error,
|
||||||
|
categoryFacets,
|
||||||
|
totalCount,
|
||||||
|
recordset,
|
||||||
|
pageSize,
|
||||||
|
pageCount,
|
||||||
|
currentPage,
|
||||||
|
offset: stateOffset,
|
||||||
|
} = this.state;
|
||||||
const filterParam = this.props.searchParams.get('filter') ?? '';
|
const filterParam = this.props.searchParams.get('filter') ?? '';
|
||||||
const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || 0);
|
const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || stateOffset || 0);
|
||||||
const categories = parseFilter(filterParam);
|
const categories = parseFilter(filterParam);
|
||||||
// Drive UI from input so hint vs results switch in one paint (URL lags one tick).
|
|
||||||
const searchActive = input.trim().length > 0 || (recordset ?? []).length > 0;
|
|
||||||
const rows = recordset ?? [];
|
const rows = recordset ?? [];
|
||||||
|
|
||||||
|
const categoriesMap = categoryAutomat.state?.categories;
|
||||||
|
const rootCategoryId = categoryAutomat.state?.rootCategoryId;
|
||||||
|
const treeOrder = getCategoryTreeOrder(categoriesMap, rootCategoryId);
|
||||||
|
|
||||||
const facetEntries = categoryFacets
|
const facetEntries = categoryFacets
|
||||||
? Object.entries(categoryFacets)
|
? Object.entries(categoryFacets)
|
||||||
.filter(([, meta]) => meta?.name)
|
.filter(([id, meta]) => {
|
||||||
|
if (!meta?.name) return false;
|
||||||
|
if (String(id) === '1' || (rootCategoryId != null && String(id) === String(rootCategoryId))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const lower = meta.name.trim().toLowerCase();
|
||||||
|
if (lower === 'kategorie 1' || lower === 'category 1' || lower === 'home') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
.map(([id, { count, name }]) => ({
|
.map(([id, { count, name }]) => ({
|
||||||
id,
|
id,
|
||||||
count,
|
count,
|
||||||
name,
|
name,
|
||||||
depth: getCategoryDepth(id, categoryFacets),
|
depth: getCategoryDepth(id, categoryFacets),
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => Number(a.id) - Number(b.id))
|
.sort((a, b) => {
|
||||||
|
const orderA = treeOrder.get(String(a.id));
|
||||||
|
const orderB = treeOrder.get(String(b.id));
|
||||||
|
if (orderA != null && orderB != null) return orderA - orderB;
|
||||||
|
if (orderA != null) return -1;
|
||||||
|
if (orderB != null) return 1;
|
||||||
|
return Number(a.id) - Number(b.id);
|
||||||
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const safePageSize = pageSize ?? 1;
|
|
||||||
const pageCount = Math.max(1, Math.ceil((totalCount ?? 0) / safePageSize));
|
|
||||||
const currentPage = Math.floor(offset / safePageSize) + 1;
|
|
||||||
const showPagination =
|
const showPagination =
|
||||||
rows.length > 0 && pageSize != null && totalCount != null && totalCount > pageSize;
|
rows.length > 0 && pageSize != null && totalCount != null && totalCount > pageSize;
|
||||||
|
|
||||||
const handlePageChange = (_e, page) => {
|
|
||||||
this.updateParams(input, filterParam, (page - 1) * safePageSize);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box sx={{ mb: 3, maxWidth: 720 }}>
|
<Box
|
||||||
|
ref={this.filterHeaderRef}
|
||||||
|
sx={{
|
||||||
|
mb: 2,
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1.5,
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
sx={{ maxWidth: 520, flexGrow: 1 }}
|
||||||
autoFocus
|
autoFocus
|
||||||
type="search"
|
type="search"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
@@ -283,6 +410,7 @@ class ArtikelSearchPage extends Component {
|
|||||||
autoCorrect: 'off',
|
autoCorrect: 'off',
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
|
sx: { height: 40 },
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
<InputAdornment position="start">
|
<InputAdornment position="start">
|
||||||
<SearchIcon color="action" />
|
<SearchIcon color="action" />
|
||||||
@@ -291,6 +419,39 @@ class ArtikelSearchPage extends Component {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{facetEntries.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant={categoryFilterOpen ? 'contained' : 'outlined'}
|
||||||
|
color="inherit"
|
||||||
|
onClick={this.toggleCategoryFilter}
|
||||||
|
startIcon={<FilterListIcon />}
|
||||||
|
endIcon={categoryFilterOpen ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||||
|
sx={{ height: 40, whiteSpace: 'nowrap', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
Kategorien
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{categories.map((catId) => {
|
||||||
|
const label = getCategoryBreadcrumbLabel(
|
||||||
|
catId,
|
||||||
|
categoriesMap,
|
||||||
|
rootCategoryId,
|
||||||
|
categoryFacets,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={catId}
|
||||||
|
label={label}
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
onDelete={() => {
|
||||||
|
const next = categories.filter((id) => id !== catId);
|
||||||
|
this.updateParams(input, buildFilter(next), 0);
|
||||||
|
}}
|
||||||
|
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -301,17 +462,47 @@ class ArtikelSearchPage extends Component {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{searchActive && (
|
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start', mb: -4 }}>
|
||||||
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
|
{categoryFilterOpen && facetEntries.length > 0 && (
|
||||||
{facetEntries.length > 0 && (
|
<FillSpace
|
||||||
<Paper variant="outlined" sx={{ width: 220, flexShrink: 0, overflow: 'auto' }}>
|
ref={this.categoryFillSpaceRef}
|
||||||
<Typography
|
aboveRef={this.filterHeaderRef}
|
||||||
variant="subtitle2"
|
sx={{ width: 240, flexShrink: 0 }}
|
||||||
sx={{ px: 2, py: 1.5, bgcolor: 'action.hover', fontWeight: 600 }}
|
|
||||||
>
|
>
|
||||||
|
<Paper
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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 }}>
|
||||||
Kategorien
|
Kategorien
|
||||||
</Typography>
|
</Typography>
|
||||||
<List dense disablePadding>
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={this.toggleCategoryFilter}
|
||||||
|
title="Kategorien einklappen"
|
||||||
|
aria-label="Kategorien einklappen"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
<List dense disablePadding sx={{ flexGrow: 1, overflowY: 'auto' }}>
|
||||||
{facetEntries.map(({ id, count, name, depth }) => {
|
{facetEntries.map(({ id, count, name, depth }) => {
|
||||||
const selected = categories.includes(id);
|
const selected = categories.includes(id);
|
||||||
return (
|
return (
|
||||||
@@ -321,6 +512,7 @@ class ArtikelSearchPage extends Component {
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = selected ? [] : [id];
|
const next = selected ? [] : [id];
|
||||||
|
this.setState({ categoryFilterOpen: false });
|
||||||
this.updateParams(input, buildFilter(next), 0);
|
this.updateParams(input, buildFilter(next), 0);
|
||||||
}}
|
}}
|
||||||
sx={{ pl: 2 + depth * 1.5 }}
|
sx={{ pl: 2 + depth * 1.5 }}
|
||||||
@@ -337,20 +529,11 @@ class ArtikelSearchPage extends Component {
|
|||||||
})}
|
})}
|
||||||
</List>
|
</List>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
</FillSpace>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={{ flexGrow: 1, minWidth: 0, mb: -4 }}>
|
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||||
{/* Same column width as the list; invisible, zero layout footprint. */}
|
{!error && input.trim().length > 0 && totalCount === 0 && (
|
||||||
<Box aria-hidden sx={{ height: 0, overflow: 'hidden', width: '100%' }}>
|
|
||||||
<Box
|
|
||||||
ref={this.setRowMeasureRef}
|
|
||||||
sx={{ visibility: 'hidden', pointerEvents: 'none' }}
|
|
||||||
>
|
|
||||||
<ResultRowMeasureSample />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{searchActive && !error && totalCount === 0 && (
|
|
||||||
<Typography color="text.secondary">Keine Artikel gefunden.</Typography>
|
<Typography color="text.secondary">Keine Artikel gefunden.</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -363,29 +546,18 @@ class ArtikelSearchPage extends Component {
|
|||||||
pageSize={pageSize ?? 1}
|
pageSize={pageSize ?? 1}
|
||||||
pageCount={showPagination ? pageCount : 5}
|
pageCount={showPagination ? pageCount : 5}
|
||||||
currentPage={showPagination ? currentPage : 1}
|
currentPage={showPagination ? currentPage : 1}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={this.handlePageChange}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<FillSpace
|
<FillSpace
|
||||||
aboveRef={this.paginationRef}
|
ref={this.fillSpaceRef}
|
||||||
debug
|
aboveRef={[this.filterHeaderRef, this.paginationRef]}
|
||||||
|
onMeasurement={this.handleMeasurement}
|
||||||
sx={{ overflow: 'auto' }}
|
sx={{ overflow: 'auto' }}
|
||||||
// Temporary dummy unit so the repeating fill is visible.
|
measureElement={<ResultRowMeasureSample />}
|
||||||
measureElement={
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: BLUE_BG,
|
|
||||||
p: 2,
|
|
||||||
borderRadius: 1,
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Typography variant="body2">Einheit</Typography>
|
<Box>
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box ref={this.setListSlotRef}>
|
|
||||||
{rows.length > 0 && (
|
{rows.length > 0 && (
|
||||||
<List component={Paper} variant="outlined" disablePadding>
|
<List component={Paper} variant="outlined" disablePadding>
|
||||||
{rows.map((row, index) => (
|
{rows.map((row, index) => (
|
||||||
@@ -409,7 +581,14 @@ class ArtikelSearchPage extends Component {
|
|||||||
variant="body2"
|
variant="body2"
|
||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
noWrap
|
noWrap
|
||||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, maxWidth: '100%' }}
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
maxWidth: '100%',
|
||||||
|
lineHeight: '20px',
|
||||||
|
height: '20px',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{(row.brand?.name || row.brandName) && (
|
{(row.brand?.name || row.brandName) && (
|
||||||
<>
|
<>
|
||||||
@@ -453,7 +632,6 @@ class ArtikelSearchPage extends Component {
|
|||||||
</FillSpace>
|
</FillSpace>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
import categoryAutomat from '../../automats/categoryAutomat';
|
import categoryAutomat from '../../automats/categoryAutomat';
|
||||||
import posterminalAutomat from '../../automats/posterminalAutomat';
|
import posterminalAutomat from '../../automats/posterminalAutomat';
|
||||||
import companyAutomat from '../../automats/companyAutomat';
|
import companyAutomat from '../../automats/companyAutomat';
|
||||||
import { setLastArticleSku } from '../../automats';
|
import { setLastArticleSku, setLastCategorySlug, setLastCategoryId } from '../../automats';
|
||||||
import {
|
import {
|
||||||
categoryDisplayName,
|
categoryDisplayName,
|
||||||
getCategoryBreadcrumbs,
|
getCategoryBreadcrumbs,
|
||||||
@@ -98,6 +98,7 @@ class ArtikelKategorienPage extends PureComponent {
|
|||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
setLastArticleSku(null);
|
setLastArticleSku(null);
|
||||||
|
setLastCategorySlug(this.props.categorySlugPath);
|
||||||
this._unsubCat = categoryAutomat.subscribe(this, selectFromCategory);
|
this._unsubCat = categoryAutomat.subscribe(this, selectFromCategory);
|
||||||
this._unsubTerm = posterminalAutomat.subscribe(this, selectTerminals);
|
this._unsubTerm = posterminalAutomat.subscribe(this, selectTerminals);
|
||||||
this._unsubCompany = companyAutomat.subscribe(this, (s) => ({
|
this._unsubCompany = companyAutomat.subscribe(this, (s) => ({
|
||||||
@@ -110,6 +111,9 @@ class ArtikelKategorienPage extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps, prevState) {
|
componentDidUpdate(prevProps, prevState) {
|
||||||
|
if (this.props.categorySlugPath !== prevProps.categorySlugPath) {
|
||||||
|
setLastCategorySlug(this.props.categorySlugPath);
|
||||||
|
}
|
||||||
if (this.state.companyId !== prevState.companyId) {
|
if (this.state.companyId !== prevState.companyId) {
|
||||||
this.ensureTreeLoaded();
|
this.ensureTreeLoaded();
|
||||||
}
|
}
|
||||||
@@ -132,6 +136,7 @@ class ArtikelKategorienPage extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
|
setLastCategorySlug(this.props.categorySlugPath);
|
||||||
this._unsubCat?.();
|
this._unsubCat?.();
|
||||||
this._unsubTerm?.();
|
this._unsubTerm?.();
|
||||||
this._unsubCompany?.();
|
this._unsubCompany?.();
|
||||||
@@ -220,8 +225,13 @@ class ArtikelKategorienPage extends PureComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isRoot = rootCategoryId != null && String(resolvedId) === String(rootCategoryId);
|
||||||
|
const activeCatId = isRoot ? null : String(resolvedId);
|
||||||
|
const fullCatPath = isRoot ? '/kategorien' : buildCategoryPath(categories, rootCategoryId, resolvedId);
|
||||||
|
setLastCategoryId(activeCatId, fullCatPath);
|
||||||
|
|
||||||
if (hasLegacyHomePrefix) {
|
if (hasLegacyHomePrefix) {
|
||||||
navigate(buildCategoryPath(categories, rootCategoryId, resolvedId), { replace: true });
|
navigate(fullCatPath, { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextHandle = categoryAutomat.key(categorySlugPath ?? '');
|
const nextHandle = categoryAutomat.key(categorySlugPath ?? '');
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { PureComponent, lazy, Suspense } from 'react';
|
|||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import AppDialog from '../../components/AppDialog';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
@@ -326,7 +326,7 @@ class ArtikelVorlagenPage extends PureComponent {
|
|||||||
const minHeightPx = getMinHeightPx(editorDpi);
|
const minHeightPx = getMinHeightPx(editorDpi);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<AppDialog
|
||||||
open={editorOpen}
|
open={editorOpen}
|
||||||
onClose={this.handleEditorClose}
|
onClose={this.handleEditorClose}
|
||||||
maxWidth={false}
|
maxWidth={false}
|
||||||
@@ -447,7 +447,7 @@ class ArtikelVorlagenPage extends PureComponent {
|
|||||||
{saving ? 'Speichert…' : 'Speichern'}
|
{saving ? 'Speichert…' : 'Speichern'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
this.handleExternalNumberChange = this.handleExternalNumberChange.bind(this);
|
this.handleExternalNumberChange = this.handleExternalNumberChange.bind(this);
|
||||||
this.handleLineChange = this.handleLineChange.bind(this);
|
this.handleLineChange = this.handleLineChange.bind(this);
|
||||||
this.handleAddLine = this.handleAddLine.bind(this);
|
this.handleAddLine = this.handleAddLine.bind(this);
|
||||||
|
this.handleAddArticleLine = this.handleAddArticleLine.bind(this);
|
||||||
this.handleRemoveLine = this.handleRemoveLine.bind(this);
|
this.handleRemoveLine = this.handleRemoveLine.bind(this);
|
||||||
this.handleRemoveAllLines = this.handleRemoveAllLines.bind(this);
|
this.handleRemoveAllLines = this.handleRemoveAllLines.bind(this);
|
||||||
this.handleSave = this.handleSave.bind(this);
|
this.handleSave = this.handleSave.bind(this);
|
||||||
@@ -506,6 +507,35 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
this.automat.actions.addLineitem();
|
this.automat.actions.addLineitem();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleAddArticleLine(article) {
|
||||||
|
if (!article) return;
|
||||||
|
const netVal =
|
||||||
|
article.price != null
|
||||||
|
? String(article.price)
|
||||||
|
: article.vknetto != null
|
||||||
|
? String(article.vknetto)
|
||||||
|
: article.net != null
|
||||||
|
? String(article.net)
|
||||||
|
: '';
|
||||||
|
const taxVal =
|
||||||
|
article.taxrate != null
|
||||||
|
? String(article.taxrate)
|
||||||
|
: article.tax != null
|
||||||
|
? String(article.tax)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
this.automat.actions.addLineitem({
|
||||||
|
amount: 1,
|
||||||
|
sku: article.sku || '',
|
||||||
|
name: article.name || '',
|
||||||
|
tax: taxVal,
|
||||||
|
taxrate: taxVal,
|
||||||
|
net: netVal,
|
||||||
|
netprice: netVal,
|
||||||
|
pu: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
handleRemoveLine(key) {
|
handleRemoveLine(key) {
|
||||||
const index = this.findLineIndex(key);
|
const index = this.findLineIndex(key);
|
||||||
this.automat.actions.removeLineitem(index >= 0 ? index : key);
|
this.automat.actions.removeLineitem(index >= 0 ? index : key);
|
||||||
@@ -1307,6 +1337,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
onLineChange={this.handleLineChange}
|
onLineChange={this.handleLineChange}
|
||||||
onSkuBlur={this.handleSkuBlur}
|
onSkuBlur={this.handleSkuBlur}
|
||||||
onAddLine={this.handleAddLine}
|
onAddLine={this.handleAddLine}
|
||||||
|
onAddArticleLine={this.handleAddArticleLine}
|
||||||
onRemoveLine={this.handleRemoveLine}
|
onRemoveLine={this.handleRemoveLine}
|
||||||
onRemoveAllLines={this.handleRemoveAllLines}
|
onRemoveAllLines={this.handleRemoveAllLines}
|
||||||
onSave={() => void this.handleSave()}
|
onSave={() => void this.handleSave()}
|
||||||
|
|||||||
@@ -289,8 +289,13 @@ export default defineConfig(({ command }) => ({
|
|||||||
...(command === 'build'
|
...(command === 'build'
|
||||||
? [
|
? [
|
||||||
VitePWA({
|
VitePWA({
|
||||||
|
// autoUpdate: the SW calls clientsClaim()/skipWaiting() itself, so a
|
||||||
|
// new build takes over every open tab on its own.
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
injectRegister: 'auto',
|
// Registration is done explicitly in src/main.jsx via
|
||||||
|
// `virtual:pwa-register` with `immediate: true`; that is what makes
|
||||||
|
// the *page* reload (a bare injected script only swaps the SW).
|
||||||
|
injectRegister: false,
|
||||||
manifest: {
|
manifest: {
|
||||||
id: '/',
|
id: '/',
|
||||||
name: 'Dashfront',
|
name: 'Dashfront',
|
||||||
@@ -320,6 +325,13 @@ export default defineConfig(({ command }) => ({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
|
// NOTE: vite-plugin-pwa only auto-sets these two when
|
||||||
|
// injectRegister === 'auto'. Because we register manually in
|
||||||
|
// src/main.jsx (injectRegister: false), they must be explicit —
|
||||||
|
// otherwise a new SW waits forever behind the old one and no tab
|
||||||
|
// is ever claimed.
|
||||||
|
skipWaiting: true,
|
||||||
|
clientsClaim: true,
|
||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest,woff2}'],
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest,woff2}'],
|
||||||
globIgnores: ['**/*.wasm'],
|
globIgnores: ['**/*.wasm'],
|
||||||
navigateFallback: 'index.html',
|
navigateFallback: 'index.html',
|
||||||
@@ -327,7 +339,19 @@ export default defineConfig(({ command }) => ({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
: []),
|
: [
|
||||||
|
{
|
||||||
|
name: 'virtual-pwa-register-stub',
|
||||||
|
resolveId(id) {
|
||||||
|
if (id === 'virtual:pwa-register') return '\0virtual:pwa-register';
|
||||||
|
},
|
||||||
|
load(id) {
|
||||||
|
if (id === '\0virtual:pwa-register') {
|
||||||
|
return 'export function registerSW() { return () => {}; }';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
server: sharedServer,
|
server: sharedServer,
|
||||||
preview: sharedServer,
|
preview: sharedServer,
|
||||||
|
|||||||
Reference in New Issue
Block a user