Compare commits

...

5 Commits

Author SHA1 Message Date
seb
26bee398a6 u 2026-10-05 14:54:43 +02:00
seb
358d34ecab u 2026-10-05 12:40:02 +02:00
seb
8c5419c89e u 2026-10-05 10:33:18 +02:00
seb
5b4cf1683a u 2026-10-05 09:17:04 +02:00
seb
8010436157 u 2026-10-05 08:31:13 +02:00
44 changed files with 2517 additions and 671 deletions

View File

@@ -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,

View File

@@ -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;
? articles.filter((row) => matches(row, query))
: articles; const articles = articleIndexAutomat.state.items ?? [];
const result = { const query = target.trim().toLowerCase();
categoryFacets: {},
totalCount: recordset.length, // Filter by text query across all tokens
recordset, const textMatches = query
error: null, ? articles.filter((row) => matches(row, query))
}; : articles;
articleSearchAutomat.setState({ result, loading: false });
return result; // Build facets for all text matches (before category filtering)
} catch (error) { const categoryFacets = buildCategoryFacets(textMatches);
const result = { ...emptyResult, error };
articleSearchAutomat.setState({ result, loading: false }); // Apply category filter if set (expanding to include all child categories)
throw error; 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 = {
categoryFacets,
totalCount,
recordset,
offset,
pageSize: safePageSize,
pageCount,
currentPage,
error: null,
};
articleSearchAutomat.setState({
result,
target,
filter,
offset,
pageSize: safePageSize,
loading: articleIndexAutomat.state.status === 'loading',
});
return result;
}, },
/** 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.
* *

View File

@@ -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.

View File

@@ -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>}
*/ */
@@ -33,9 +48,9 @@ const navigationAutomat = new Automat(
{ {
/** /**
* Set the last visited delivery path. * Set the last visited delivery path.
* *
* @param {string|null} path Full path such as `/lieferung/19` or `/lieferung/neu`. * @param {string|null} path Full path such as `/lieferung/19` or `/lieferung/neu`.
* @returns {void} * @returns {void}
*/ */
setLastDeliveryPath(path) { setLastDeliveryPath(path) {
navigationAutomat.setState({ lastDeliveryPath: path }); navigationAutomat.setState({ lastDeliveryPath: path });
@@ -43,24 +58,128 @@ const navigationAutomat = new Automat(
/** /**
* Set the last visited article SKU. * Set the last visited article SKU.
* *
* @param {string|null} sku * @param {string|null} sku
* @returns {void} * @returns {void}
*/ */
setLastArticleSku(sku) { setLastArticleSku(sku) {
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;

View File

@@ -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,

View 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;

View File

@@ -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);

View File

@@ -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>
); );
} }
} }

View File

@@ -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>
); );
} }
} }

View File

@@ -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>
); );
} }
} }

View File

@@ -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 } : {}),
inputLabel: { ...(hasLabelProps
...(label ? { shrink: true } : {}), ? {
...InputLabelProps, inputLabel: {
...slotProps?.inputLabel, ...(label ? { shrink: true } : {}),
}, ...InputLabelProps,
...slotProps?.inputLabel,
},
}
: {}),
htmlInput, htmlInput,
}; };

View File

@@ -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>
); );
} }
} }

View File

@@ -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>
); );
} }
} }

View File

@@ -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"

View 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>
); );
} }
} }

View 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>
&nbsp;
</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)}
</>
);
}
}

View File

@@ -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>
); );
} }
} }

View File

@@ -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>
); );
} }
} }

View File

@@ -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>
); );
} }
} }

View File

@@ -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>
); );
} }
} }

View File

@@ -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} label="Artikel"
placeholder="Artikel suchen..."
value={stuecklisteTargetArticle} value={stuecklisteTargetArticle}
disabled={busy} disabled={busy}
onChange={(_e, val) => this.setState({ stuecklisteTargetArticle: val })} excludeSkus={[article?.sku, ...stuecklisteItems.map((item) => item.component_sku || item.sku)]}
getOptionLabel={(opt) => onSelect={(val) => this.setState({ stuecklisteTargetArticle: val })}
typeof opt === 'string' ? opt : (opt ? `${opt.sku} - ${opt.name || ''}` : '')
}
isOptionEqualToValue={(opt, val) => opt?.sku === val?.sku}
renderInput={(params) => (
<TextInput
{...params}
label="Artikel"
placeholder="Artikel suchen..."
/>
)}
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} label="Option"
placeholder="Option suchen..."
value={optionTargetArticle} value={optionTargetArticle}
disabled={busy} disabled={busy}
onChange={(_e, val) => this.setState({ optionTargetArticle: val })} excludeSkus={[article?.sku, ...optionsItems.map((item) => item.component_sku || item.sku)]}
getOptionLabel={(opt) => onSelect={(val) => this.setState({ optionTargetArticle: val })}
typeof opt === 'string' ? opt : (opt ? `${opt.sku} - ${opt.name || ''}` : '')
}
isOptionEqualToValue={(opt, val) => opt?.sku === val?.sku}
renderInput={(params) => (
<TextInput
{...params}
label="Option"
placeholder="Option suchen..."
/>
)}
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>
); );
} }

View File

@@ -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';

View File

@@ -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

View File

@@ -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

View File

@@ -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>
); );
} }
} }

View File

@@ -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',

View File

@@ -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,

View File

@@ -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>
); );
} }
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }
} }

View File

@@ -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 &bdquo;{query}&ldquo; 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>
); );
} }
} }

View File

@@ -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>
); );
} }
} }

View File

@@ -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}
/>
</> </>
); );
} }

View File

@@ -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"

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }
} }

View File

@@ -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>
); );
} }
} }

View File

@@ -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.

View File

@@ -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 />

View File

@@ -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 &nbsp;
</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' }}
>
&nbsp;
</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,
}}
> >
Kategorien <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
</Typography> Kategorien
<List dense disablePadding> </Typography>
<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,123 +529,109 @@ class ArtikelSearchPage extends Component {
})} })}
</List> </List>
</Paper> </Paper>
</FillSpace>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
{!error && input.trim().length > 0 && totalCount === 0 && (
<Typography color="text.secondary">Keine Artikel gefunden.</Typography>
)} )}
<Box sx={{ flexGrow: 1, minWidth: 0, mb: -4 }}> {/* Always mount real pagination height (invisible until needed) so pageSize is correct. */}
{/* Same column width as the list; invisible, zero layout footprint. */} <Box ref={this.paginationRef} sx={{ mb: 2 }}>
<Box aria-hidden sx={{ height: 0, overflow: 'hidden', width: '100%' }}> <ResultsPagination
<Box invisible={!showPagination}
ref={this.setRowMeasureRef} offset={showPagination ? offset : 0}
sx={{ visibility: 'hidden', pointerEvents: 'none' }} totalCount={showPagination ? totalCount : (pageSize ?? 1) + 1}
> pageSize={pageSize ?? 1}
<ResultRowMeasureSample /> pageCount={showPagination ? pageCount : 5}
</Box> currentPage={showPagination ? currentPage : 1}
</Box> onPageChange={this.handlePageChange}
/>
{searchActive && !error && totalCount === 0 && (
<Typography color="text.secondary">Keine Artikel gefunden.</Typography>
)}
{/* Always mount real pagination height (invisible until needed) so pageSize is correct. */}
<Box ref={this.paginationRef} sx={{ mb: 2 }}>
<ResultsPagination
invisible={!showPagination}
offset={showPagination ? offset : 0}
totalCount={showPagination ? totalCount : (pageSize ?? 1) + 1}
pageSize={pageSize ?? 1}
pageCount={showPagination ? pageCount : 5}
currentPage={showPagination ? currentPage : 1}
onPageChange={handlePageChange}
/>
</Box>
<FillSpace
aboveRef={this.paginationRef}
debug
sx={{ overflow: 'auto' }}
// Temporary dummy unit so the repeating fill is visible.
measureElement={
<Box
sx={{
bgcolor: BLUE_BG,
p: 2,
borderRadius: 1,
textAlign: 'center',
}}
>
<Typography variant="body2">Einheit</Typography>
</Box>
}
>
<Box ref={this.setListSlotRef}>
{rows.length > 0 && (
<List component={Paper} variant="outlined" disablePadding>
{rows.map((row, index) => (
<ListItemButton
key={row.id}
type="button"
divider={index < rows.length - 1}
data-result-row
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
this.persistQuery();
this.props.navigate(artikelDetailPath(row));
}}
sx={resultRowSx}
>
<ListItemText
primary={row.name}
secondary={
<Typography
component="span"
variant="body2"
color="text.secondary"
noWrap
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, maxWidth: '100%' }}
>
{(row.brand?.name || row.brandName) && (
<>
<BrandLabel
brand={row.brand || { name: row.brandName, thumbHash: row.brandThumbHash }}
name={row.brand?.name || row.brandName}
size={14}
spacing={0.5}
typographyProps={{
variant: 'body2',
color: 'text.secondary',
noWrap: true,
}}
/>
{' · '}
</>
)}
{row.sku}
{' · '}
Bestand {formatStock(row.stock)}
{' · '}
Verfügbar {formatStock(row.available)}
{row.categories
? ` · ${formatCategoryList(row.categories, categoryFacets ?? {})}`
: ''}
</Typography>
}
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
/>
<Typography
variant="body2"
sx={{ fontWeight: 600, flexShrink: 0, ml: 2 }}
>
{row.price != null ? formatPrice(row.price) : '—'}
</Typography>
</ListItemButton>
))}
</List>
)}
</Box>
</FillSpace>
</Box> </Box>
<FillSpace
ref={this.fillSpaceRef}
aboveRef={[this.filterHeaderRef, this.paginationRef]}
onMeasurement={this.handleMeasurement}
sx={{ overflow: 'auto' }}
measureElement={<ResultRowMeasureSample />}
>
<Box>
{rows.length > 0 && (
<List component={Paper} variant="outlined" disablePadding>
{rows.map((row, index) => (
<ListItemButton
key={row.id}
type="button"
divider={index < rows.length - 1}
data-result-row
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
this.persistQuery();
this.props.navigate(artikelDetailPath(row));
}}
sx={resultRowSx}
>
<ListItemText
primary={row.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',
}}
>
{(row.brand?.name || row.brandName) && (
<>
<BrandLabel
brand={row.brand || { name: row.brandName, thumbHash: row.brandThumbHash }}
name={row.brand?.name || row.brandName}
size={14}
spacing={0.5}
typographyProps={{
variant: 'body2',
color: 'text.secondary',
noWrap: true,
}}
/>
{' · '}
</>
)}
{row.sku}
{' · '}
Bestand {formatStock(row.stock)}
{' · '}
Verfügbar {formatStock(row.available)}
{row.categories
? ` · ${formatCategoryList(row.categories, categoryFacets ?? {})}`
: ''}
</Typography>
}
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
/>
<Typography
variant="body2"
sx={{ fontWeight: 600, flexShrink: 0, ml: 2 }}
>
{row.price != null ? formatPrice(row.price) : '—'}
</Typography>
</ListItemButton>
))}
</List>
)}
</Box>
</FillSpace>
</Box> </Box>
)} </Box>
</> </>
); );
} }

View File

@@ -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 ?? '');

View File

@@ -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>
); );
} }

View File

@@ -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()}

View File

@@ -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,