u
This commit is contained in:
@@ -86,17 +86,248 @@ function matches(row, query) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an article belongs to at least one of the selected categories.
|
||||
* 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 {Array<string>} selectedCategories Array of category ID strings.
|
||||
* @param {Set<string>} expandedCategoryIds Set of category ID strings.
|
||||
* @returns {boolean} Whether the article matches.
|
||||
*/
|
||||
function matchesCategory(row, selectedCategories) {
|
||||
if (!selectedCategories || selectedCategories.length === 0) return true;
|
||||
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 selectedCategories.some((catId) => rowCats.includes(String(catId)));
|
||||
return rowCats.some((catId) => expandedCategoryIds.has(String(catId)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,16 +339,32 @@ function matchesCategory(row, selectedCategories) {
|
||||
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]) {
|
||||
const catNode = categoriesMap[id];
|
||||
facets[id] = {
|
||||
count: 0,
|
||||
name: catNode?.name ?? `Kategorie ${id}`,
|
||||
name,
|
||||
parent: catNode?.parentId ?? null,
|
||||
};
|
||||
}
|
||||
@@ -153,19 +400,31 @@ const actions = {
|
||||
// Build facets for all text matches (before category filtering)
|
||||
const categoryFacets = buildCategoryFacets(textMatches);
|
||||
|
||||
// Apply category filter if set
|
||||
// Apply category filter if set (expanding to include all child categories)
|
||||
const selectedCategories = parseFilter(filter);
|
||||
const filteredArticles = selectedCategories.length > 0
|
||||
? textMatches.filter((row) => matchesCategory(row, selectedCategories))
|
||||
: textMatches;
|
||||
const categoriesMap = categoryAutomat.state?.categories ?? {};
|
||||
let filteredArticles = textMatches;
|
||||
|
||||
const totalCount = filteredArticles.length;
|
||||
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
|
||||
? filteredArticles.slice(offset, offset + safePageSize)
|
||||
: filteredArticles;
|
||||
? sortedArticles.slice(offset, offset + safePageSize)
|
||||
: sortedArticles;
|
||||
|
||||
const result = {
|
||||
categoryFacets,
|
||||
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
setLastArticleSku,
|
||||
setLastSearchTerm,
|
||||
setLastArticleSearchPath,
|
||||
setLastCategoryId,
|
||||
setLastCategorySlug,
|
||||
setLastCategoryPath,
|
||||
clearNavigation,
|
||||
@@ -38,7 +39,11 @@ export { default as supplierAutomat } from './supplierAutomat';
|
||||
export { default as warehouseAutomat } from './warehouseAutomat';
|
||||
|
||||
/** 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.
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
*/
|
||||
|
||||
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
|
||||
@@ -18,6 +21,7 @@ import { Automat } from 'react-automat';
|
||||
* @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`).
|
||||
*/
|
||||
@@ -28,6 +32,7 @@ const initialState = {
|
||||
lastArticleSku: null,
|
||||
lastSearchTerm: null,
|
||||
lastArticleSearchPath: null,
|
||||
lastCategoryId: null,
|
||||
lastCategorySlug: null,
|
||||
lastCategoryPath: null,
|
||||
};
|
||||
@@ -70,7 +75,8 @@ const navigationAutomat = new Automat(
|
||||
*/
|
||||
setLastSearchTerm(term, path = null) {
|
||||
const trimmed = term != null ? String(term) : '';
|
||||
const searchPath = path ?? (trimmed ? `/artikel?target=${encodeURIComponent(trimmed)}` : '/artikel');
|
||||
const currentCatId = navigationAutomat.state.lastCategoryId ?? '';
|
||||
const searchPath = path ?? buildSearchUrl(trimmed, currentCatId, 0);
|
||||
navigationAutomat.setState({
|
||||
lastSearchTerm: trimmed || null,
|
||||
lastArticleSearchPath: searchPath,
|
||||
@@ -91,6 +97,41 @@ const navigationAutomat = new Automat(
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -100,10 +141,7 @@ const navigationAutomat = new Automat(
|
||||
setLastCategorySlug(slug) {
|
||||
const clean = slug ? String(slug).replace(/^\/+|\/+$/g, '') : '';
|
||||
const path = clean ? `/kategorien/${clean}` : '/kategorien';
|
||||
navigationAutomat.setState({
|
||||
lastCategorySlug: clean || null,
|
||||
lastCategoryPath: path,
|
||||
});
|
||||
navigationAutomat.actions.setLastCategoryPath(path);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -114,10 +152,24 @@ const navigationAutomat = new Automat(
|
||||
*/
|
||||
setLastCategoryPath(path) {
|
||||
const cleanPath = path ? String(path).replace(/^\/+/, '/') : '/kategorien';
|
||||
const slug = cleanPath.replace(/^\/kategorien\/?/, '').replace(/\/+$/, '');
|
||||
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({
|
||||
lastCategorySlug: slug || null,
|
||||
lastCategoryId: catId,
|
||||
lastCategorySlug: isRoot ? null : slug,
|
||||
lastCategoryPath: cleanPath,
|
||||
lastArticleSearchPath: searchPath,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -142,6 +194,7 @@ export const {
|
||||
setLastArticleSku,
|
||||
setLastSearchTerm,
|
||||
setLastArticleSearchPath,
|
||||
setLastCategoryId,
|
||||
setLastCategorySlug,
|
||||
setLastCategoryPath,
|
||||
clearNavigation,
|
||||
|
||||
878
src/components/article/ArticleSearch.jsx
Normal file
878
src/components/article/ArticleSearch.jsx
Normal file
@@ -0,0 +1,878 @@
|
||||
import { PureComponent, createRef } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
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 TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Autocomplete from '@mui/material/Autocomplete';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import FillSpace from 'dashgui/FillSpace';
|
||||
import articleIndexAutomat from '../../automats/articleIndexAutomat';
|
||||
import { searchArticlesIndex } from '../../automats/articleSearchAutomat';
|
||||
import ArticlePhoto from './ArticlePhoto';
|
||||
import { BrandLabel } from '../brand';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import { formatStock } from './searchFormat';
|
||||
import ResultsPagination from './ResultsPagination';
|
||||
import { BORDER } from '../../colors';
|
||||
|
||||
const searchResultRowSx = {
|
||||
alignItems: 'center',
|
||||
minHeight: 64,
|
||||
maxHeight: 64,
|
||||
height: 64,
|
||||
boxSizing: 'border-box',
|
||||
py: 0,
|
||||
px: 2,
|
||||
'& .MuiListItemText-root': { minWidth: 0, my: 0 },
|
||||
'& .MuiListItemText-primary': {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: '22px',
|
||||
height: '22px',
|
||||
},
|
||||
'& .MuiListItemText-secondary': {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: '20px',
|
||||
height: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
};
|
||||
|
||||
/** Measure row that precisely matches the rendered result row height. */
|
||||
function SearchRowMeasureSample() {
|
||||
return (
|
||||
<ListItemButton
|
||||
tabIndex={-1}
|
||||
divider
|
||||
disabled
|
||||
sx={{
|
||||
...searchResultRowSx,
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
opacity: 0,
|
||||
}}
|
||||
>
|
||||
<ListItemAvatar sx={{ minWidth: 48 }}>
|
||||
<Box sx={{ width: 38, height: 38 }} />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={'\u00A0'}
|
||||
secondary={
|
||||
<Typography component="span" variant="body2" sx={{ visibility: 'hidden', lineHeight: '20px', height: '20px' }} noWrap>
|
||||
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<Box sx={{ width: 140, height: 28, visibility: 'hidden' }} />
|
||||
</ListItemButton>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified article search component based on articleSearchAutomat / articleIndexAutomat.
|
||||
*
|
||||
* Supported Modes:
|
||||
* 1. 'dialog' (default): Inline trigger field that opens a spacious modal dialog.
|
||||
* 2. 'list': Standalone search input with rich results list and dynamic FillSpace pagination.
|
||||
* 3. 'autocomplete': Standard Autocomplete with drop-down popper.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {'dialog'|'list'|'autocomplete'} [props.mode='dialog']
|
||||
* @param {string} [props.placeholder='Artikel suchen…']
|
||||
* @param {string} [props.label]
|
||||
* @param {object|null} [props.value] Currently selected article.
|
||||
* @param {Function} [props.onSelect] Callback when article selected: (article) => void.
|
||||
* @param {Function} [props.onChange] Alternative callback for selection.
|
||||
* @param {Array<string>} [props.excludeSkus=[]] SKUs to exclude.
|
||||
* @param {boolean} [props.allowParent=false] Whether parent articles are selectable.
|
||||
* @param {Array<string>} [props.categories=[]] Category IDs to filter by.
|
||||
* @param {number} [props.pageSize] Initial results per page (measured dynamically by FillSpace).
|
||||
* @param {boolean} [props.clearOnSelect=false]
|
||||
* @param {boolean} [props.disabled=false]
|
||||
* @param {boolean} [props.autoFocus=false]
|
||||
* @param {'small'|'medium'} [props.size='small']
|
||||
* @param {object} [props.sx]
|
||||
* @param {boolean} [props.open] For controlled dialog: whether dialog is open.
|
||||
* @param {Function} [props.onClose] For controlled dialog: close callback.
|
||||
* @param {string} [props.title] Dialog title.
|
||||
* @param {string} [props.actionLabel='Auswählen'] Label on selection button.
|
||||
*/
|
||||
export default class ArticleSearch extends PureComponent {
|
||||
headerRef = createRef();
|
||||
paginationRef = createRef();
|
||||
listContainerRef = createRef();
|
||||
contentContainerRef = createRef();
|
||||
fillSpaceRef = createRef();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const initialQuery = props.initialQuery ?? '';
|
||||
const pageSize = props.pageSize ?? 8;
|
||||
this.state = {
|
||||
query: initialQuery,
|
||||
offset: 0,
|
||||
pageSize,
|
||||
totalCount: 0,
|
||||
pageCount: 1,
|
||||
currentPage: 1,
|
||||
searchKey: 0,
|
||||
results: [],
|
||||
indexStatus: articleIndexAutomat.state.status,
|
||||
busySku: props.busySku || null,
|
||||
error: null,
|
||||
internalOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubIndex = articleIndexAutomat.subscribe(this, (state) => ({
|
||||
indexStatus: state.status,
|
||||
}));
|
||||
|
||||
if (articleIndexAutomat.state.status === 'idle') {
|
||||
void articleIndexAutomat.actions.ensureLoaded().catch(() => {});
|
||||
}
|
||||
|
||||
this.runQuery(this.state.query, 0);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (
|
||||
this.state.indexStatus !== prevState.indexStatus ||
|
||||
this.props.excludeSkus !== prevProps.excludeSkus ||
|
||||
this.props.allowParent !== prevProps.allowParent ||
|
||||
this.props.categories !== prevProps.categories
|
||||
) {
|
||||
this.runQuery(this.state.query, this.state.offset);
|
||||
}
|
||||
|
||||
if (this.props.busySku !== prevProps.busySku) {
|
||||
this.setState({ busySku: this.props.busySku });
|
||||
}
|
||||
|
||||
if (this.props.initialQuery !== prevProps.initialQuery && this.props.initialQuery != null) {
|
||||
this.setState({ query: this.props.initialQuery, offset: 0 });
|
||||
this.runQuery(this.props.initialQuery, 0);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubIndex?.();
|
||||
}
|
||||
|
||||
handleMeasurement = (count) => {
|
||||
if (!count || count <= 0 || count === this.state.pageSize) return;
|
||||
this.setState({ pageSize: count });
|
||||
this.runQuery(this.state.query, this.state.offset, count);
|
||||
};
|
||||
|
||||
runQuery = (queryText, newOffset = 0, customPageSize) => {
|
||||
const {
|
||||
excludeSkus = [],
|
||||
allowParent = false,
|
||||
categories = [],
|
||||
} = this.props;
|
||||
|
||||
const pageSize = customPageSize ?? this.state.pageSize;
|
||||
|
||||
const res = searchArticlesIndex(queryText, {
|
||||
excludeSkus,
|
||||
allowParent,
|
||||
categories,
|
||||
offset: newOffset,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
this.setState({
|
||||
results: Array.isArray(res) ? res : (res.recordset ?? []),
|
||||
totalCount: res.totalCount ?? res.length,
|
||||
pageCount: res.pageCount ?? 1,
|
||||
currentPage: res.currentPage ?? 1,
|
||||
offset: newOffset,
|
||||
query: queryText,
|
||||
pageSize,
|
||||
});
|
||||
};
|
||||
|
||||
handlePageChange = (_e, page) => {
|
||||
const nextOffset = (page - 1) * this.state.pageSize;
|
||||
this.runQuery(this.state.query, nextOffset);
|
||||
};
|
||||
|
||||
handleOpenDialog = () => {
|
||||
const initialQuery = this.props.initialQuery ?? '';
|
||||
this.setState({ internalOpen: true, query: initialQuery, offset: 0 });
|
||||
this.runQuery(initialQuery, 0);
|
||||
requestAnimationFrame(() => {
|
||||
this.fillSpaceRef.current?.measure();
|
||||
});
|
||||
};
|
||||
|
||||
handleCloseDialog = () => {
|
||||
this.setState({ internalOpen: false });
|
||||
this.props.onClose?.();
|
||||
};
|
||||
|
||||
handleSelectArticle = (article) => {
|
||||
const { onSelect, onChange, clearOnSelect } = this.props;
|
||||
|
||||
if (article) {
|
||||
this.setState({ internalOpen: false });
|
||||
this.props.onClose?.();
|
||||
}
|
||||
|
||||
if (clearOnSelect) {
|
||||
this.setState((prev) => ({
|
||||
query: '',
|
||||
offset: 0,
|
||||
searchKey: prev.searchKey + 1,
|
||||
}));
|
||||
this.runQuery('', 0);
|
||||
}
|
||||
|
||||
onSelect?.(article);
|
||||
onChange?.(article);
|
||||
};
|
||||
|
||||
renderTrigger() {
|
||||
const {
|
||||
placeholder = 'Artikel suchen…',
|
||||
label,
|
||||
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 (
|
||||
<TextField
|
||||
fullWidth
|
||||
type="search"
|
||||
autoComplete="off"
|
||||
size={size}
|
||||
label={label}
|
||||
placeholder={placeholder || 'Artikel suchen (EAN, Artikelnr., Name, Barcode)…'}
|
||||
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();
|
||||
}
|
||||
}}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
autoCapitalize: 'off',
|
||||
autoCorrect: 'off',
|
||||
},
|
||||
input: {
|
||||
readOnly: true,
|
||||
sx: {
|
||||
height: 40,
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
textOverflow: 'ellipsis',
|
||||
'& input': {
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
},
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon 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: 18 }} />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
) : null,
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderListContent() {
|
||||
const {
|
||||
placeholder = 'Artikel nach SKU, Name oder EAN suchen…',
|
||||
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 }}>
|
||||
<TextField
|
||||
autoFocus={autoFocus}
|
||||
fullWidth
|
||||
type="search"
|
||||
autoComplete="off"
|
||||
placeholder={placeholder || 'Artikel suchen (EAN, Artikelnr., Name, Barcode)…'}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
this.setState({ query: val, offset: 0 });
|
||||
this.runQuery(val, 0);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && results.length > 0) {
|
||||
e.preventDefault();
|
||||
this.handleSelectArticle(results[0]);
|
||||
}
|
||||
}}
|
||||
disabled={Boolean(busySku)}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
autoCapitalize: 'off',
|
||||
autoCorrect: 'off',
|
||||
},
|
||||
input: {
|
||||
sx: { height: 40 },
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon color="action" />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: query ? (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
this.setState({ query: '', offset: 0 });
|
||||
this.runQuery('', 0);
|
||||
}}
|
||||
edge="end"
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 18 }} />
|
||||
</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 (
|
||||
<Dialog
|
||||
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>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<TextField
|
||||
{...params}
|
||||
autoFocus={autoFocus}
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
startAdornment,
|
||||
endAdornment,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
{...params}
|
||||
autoFocus={autoFocus}
|
||||
label={label}
|
||||
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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import MenuItem from '@mui/material/MenuItem';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import TextInput from '../../TextInput';
|
||||
import ArticleSearch from '../ArticleSearch';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
@@ -611,6 +612,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
};
|
||||
|
||||
renderStuecklisteContent(stuecklisteItems, selectableOtherArticles, isStueckliste = true) {
|
||||
const { article } = this.props;
|
||||
const { busy, stuecklisteTargetArticle, stuecklisteCount } = this.state;
|
||||
return (
|
||||
<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 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
||||
<Autocomplete
|
||||
<ArticleSearch
|
||||
size="small"
|
||||
options={selectableOtherArticles}
|
||||
label="Artikel"
|
||||
placeholder="Artikel suchen..."
|
||||
value={stuecklisteTargetArticle}
|
||||
disabled={busy}
|
||||
onChange={(_e, val) => this.setState({ stuecklisteTargetArticle: val })}
|
||||
getOptionLabel={(opt) =>
|
||||
typeof opt === 'string' ? opt : (opt ? `${opt.sku} - ${opt.name || ''}` : '')
|
||||
}
|
||||
isOptionEqualToValue={(opt, val) => opt?.sku === val?.sku}
|
||||
renderInput={(params) => (
|
||||
<TextInput
|
||||
{...params}
|
||||
label="Artikel"
|
||||
placeholder="Artikel suchen..."
|
||||
/>
|
||||
)}
|
||||
excludeSkus={[article?.sku, ...stuecklisteItems.map((item) => item.component_sku || item.sku)]}
|
||||
onSelect={(val) => this.setState({ stuecklisteTargetArticle: val })}
|
||||
sx={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
|
||||
@@ -807,6 +800,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
}
|
||||
|
||||
renderOptionsContent(optionsItems, selectableOtherArticles, hasOptions = true) {
|
||||
const { article } = this.props;
|
||||
const { busy, optionTargetArticle, optionCount, optionMandated } = this.state;
|
||||
return (
|
||||
<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 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
||||
<Autocomplete
|
||||
<ArticleSearch
|
||||
size="small"
|
||||
options={selectableOtherArticles}
|
||||
label="Option"
|
||||
placeholder="Option suchen..."
|
||||
value={optionTargetArticle}
|
||||
disabled={busy}
|
||||
onChange={(_e, val) => this.setState({ optionTargetArticle: val })}
|
||||
getOptionLabel={(opt) =>
|
||||
typeof opt === 'string' ? opt : (opt ? `${opt.sku} - ${opt.name || ''}` : '')
|
||||
}
|
||||
isOptionEqualToValue={(opt, val) => opt?.sku === val?.sku}
|
||||
renderInput={(params) => (
|
||||
<TextInput
|
||||
{...params}
|
||||
label="Option"
|
||||
placeholder="Option suchen..."
|
||||
/>
|
||||
)}
|
||||
excludeSkus={[article?.sku, ...optionsItems.map((item) => item.component_sku || item.sku)]}
|
||||
onSelect={(val) => this.setState({ optionTargetArticle: val })}
|
||||
sx={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@ export { default as ArticlePhotosDialog } from './ArticlePhotosDialog';
|
||||
export { default as ArticleImageSearchDialog } from './ArticleImageSearchDialog';
|
||||
export { default as ArticleTileTab } from './ArticleTileTab';
|
||||
export { default as NewArticleTile } from './NewArticleTile';
|
||||
export { default as ArticleSearch } from './ArticleSearch';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getCategoryBreadcrumbs, ROOT_CATEGORY_NAME } from '../../utils/categoryUtils';
|
||||
|
||||
export function parseFilter(filterStr) {
|
||||
if (!filterStr) return [];
|
||||
return filterStr.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
@@ -15,13 +17,62 @@ export function formatStock(value) {
|
||||
export function getCategoryDepth(id, facets) {
|
||||
let depth = 0;
|
||||
let parent = facets[id]?.parent;
|
||||
while (parent && parent !== 0 && facets[String(parent)]) {
|
||||
while (
|
||||
parent &&
|
||||
parent !== 0 &&
|
||||
String(parent) !== '1' &&
|
||||
facets[String(parent)]
|
||||
) {
|
||||
depth += 1;
|
||||
parent = facets[String(parent)]?.parent;
|
||||
}
|
||||
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) {
|
||||
if (!catStr) return '';
|
||||
return catStr
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function BrandLabel({
|
||||
alignItems: 'center',
|
||||
gap: spacing,
|
||||
verticalAlign: 'middle',
|
||||
lineHeight: 1,
|
||||
maxWidth: '100%',
|
||||
...sx,
|
||||
}}
|
||||
@@ -39,6 +40,7 @@ export default function BrandLabel({
|
||||
variant="body2"
|
||||
{...typographyProps}
|
||||
sx={{
|
||||
lineHeight: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
|
||||
@@ -101,6 +101,9 @@ export default function BrandThumb({
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
verticalAlign: 'middle',
|
||||
lineHeight: 0,
|
||||
my: 0,
|
||||
boxSizing: 'border-box',
|
||||
bgcolor: TINT_ROW_HOVER,
|
||||
boxShadow: SHADOW_THUMB_RING,
|
||||
...sx,
|
||||
|
||||
@@ -1,94 +1,32 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
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 TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
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';
|
||||
import ArticleSearch from '../article/ArticleSearch';
|
||||
|
||||
export default class ArticleSearchDialog extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
query: '',
|
||||
articles: [],
|
||||
loading: false,
|
||||
error: 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) {
|
||||
if (!prevProps.open && this.props.open) {
|
||||
this.initFromLine();
|
||||
void this.loadArticles();
|
||||
this.setState({ assigningSku: null, error: null });
|
||||
}
|
||||
}
|
||||
|
||||
initFromLine() {
|
||||
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) {
|
||||
handleSelect = async (article) => {
|
||||
const { onAssign, line } = this.props;
|
||||
if (!onAssign || !article?.sku) return;
|
||||
|
||||
@@ -106,34 +44,43 @@ export default class ArticleSearchDialog extends PureComponent {
|
||||
error: err?.message || 'Fehler beim Verknüpfen des Lieferanten-Artikels.',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { open, onClose, line } = this.props;
|
||||
const { query, articles, loading, error, assigningSku } = 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 { assigningSku, error } = this.state;
|
||||
const rawSku = line?.sku != null ? String(line.sku).trim() : '';
|
||||
const initialQuery = line?.name?.trim() || line?.sku?.trim() || '';
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
open={Boolean(open)}
|
||||
onClose={assigningSku ? undefined : onClose}
|
||||
maxWidth="sm"
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
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
|
||||
id="article-search-dialog-title"
|
||||
@@ -142,6 +89,7 @@ export default class ArticleSearchDialog extends PureComponent {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
pb: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
@@ -163,183 +111,32 @@ export default class ArticleSearchDialog extends PureComponent {
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers sx={{ p: 2 }}>
|
||||
<DialogContent
|
||||
dividers
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
<Alert severity="error" sx={{ mb: 2, flexShrink: 0 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
<ArticleSearch
|
||||
mode="list"
|
||||
autoFocus
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Artikel nach SKU, Name oder EAN suchen…"
|
||||
value={query}
|
||||
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 }}
|
||||
initialQuery={initialQuery}
|
||||
allowParent={false}
|
||||
busySku={assigningSku}
|
||||
actionLabel={assigningSku ? 'Zuordnen…' : 'Zuordnen'}
|
||||
onSelect={this.handleSelect}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', py: 6, gap: 1.5 }}>
|
||||
<CircularProgress size={20} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Artikel werden geladen…
|
||||
</Typography>
|
||||
</Box>
|
||||
) : filtered.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 5 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Keine Artikel für „{query}“ gefunden.
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<List
|
||||
dense
|
||||
disablePadding
|
||||
sx={{
|
||||
maxHeight: 380,
|
||||
overflowY: 'auto',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
{filtered.map((article) => {
|
||||
const photo = article.photos?.[0] || article.articlepic?.[0];
|
||||
const fullHash = photo?.hash || photo?.fullHash;
|
||||
const thumbHash = photo?.thumbhash || photo?.thumbHash;
|
||||
const thumbW = photo?.thumbw || photo?.thumbW;
|
||||
const thumbH = photo?.thumbh || photo?.thumbH;
|
||||
const hasPhoto = fullHash != null || thumbHash != null;
|
||||
const primaryEan = Array.isArray(article.eans) ? article.eans[0] : article.eans;
|
||||
const isAssigning = assigningSku === article.sku;
|
||||
|
||||
return (
|
||||
<ListItemButton
|
||||
key={article.id || article.sku}
|
||||
divider
|
||||
disabled={Boolean(assigningSku)}
|
||||
onClick={() => this.handleSelect(article)}
|
||||
sx={{
|
||||
py: 1,
|
||||
px: 1.5,
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemAvatar sx={{ minWidth: 44 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 0.75,
|
||||
overflow: 'hidden',
|
||||
bgcolor: BORDER,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
{hasPhoto ? (
|
||||
<ArticlePhoto
|
||||
photo={{ fullHash, thumbHash, thumbW, thumbH }}
|
||||
size={34}
|
||||
/>
|
||||
) : (
|
||||
<Inventory2OutlinedIcon sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||||
)}
|
||||
</Box>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={article.sku}
|
||||
sx={{
|
||||
height: 18,
|
||||
fontSize: '0.68rem',
|
||||
fontWeight: 600,
|
||||
bgcolor: 'primary.50',
|
||||
color: 'primary.dark',
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" fontWeight={600} noWrap sx={{ maxWidth: 300 }}>
|
||||
{article.name || '–'}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.25 }}>
|
||||
{(article.brand?.name || article.brandName) && (
|
||||
<BrandLabel
|
||||
brand={article.brand || { name: article.brandName, thumbHash: article.brandThumbHash }}
|
||||
name={article.brand?.name || article.brandName}
|
||||
size={14}
|
||||
typographyProps={{
|
||||
variant: 'caption',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{primaryEan ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontFamily: 'monospace', fontSize: '0.7rem' }}
|
||||
>
|
||||
EAN: {primaryEan}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={isAssigning ? <CircularProgress size={12} /> : <LinkIcon sx={{ fontSize: 14 }} />}
|
||||
disabled={Boolean(assigningSku)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void this.handleSelect(article);
|
||||
}}
|
||||
sx={{
|
||||
fontSize: '0.72rem',
|
||||
textTransform: 'none',
|
||||
py: 0.25,
|
||||
px: 1.25,
|
||||
ml: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{isAssigning ? 'Zuordnen…' : 'Zuordnen'}
|
||||
</Button>
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 2, py: 1.5 }}>
|
||||
|
||||
@@ -12,6 +12,7 @@ import withNavigate from '../withNavigate';
|
||||
import ManifestHeader from './ManifestHeader';
|
||||
import ManifestLines from './ManifestLines';
|
||||
import ManifestPdfDialog from './ManifestPdfDialog';
|
||||
import ArticleSearchDialog from './ArticleSearchDialog';
|
||||
import {
|
||||
buildManifestBody,
|
||||
restoreAttachmentsFromApi,
|
||||
@@ -43,6 +44,8 @@ class ManifestForm extends PureComponent {
|
||||
pdfDialogOpen: false,
|
||||
pdfDialogData: null,
|
||||
pdfFileName: '',
|
||||
articleSearchDialogOpen: false,
|
||||
articleSearchLine: null,
|
||||
};
|
||||
this._cancelled = false;
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
@@ -53,6 +56,10 @@ class ManifestForm extends PureComponent {
|
||||
this.handleExternalNumberChange.bind(this);
|
||||
this.handleLineChange = this.handleLineChange.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.handleRemoveAllLines = this.handleRemoveAllLines.bind(this);
|
||||
this.handleSave = this.handleSave.bind(this);
|
||||
@@ -205,6 +212,95 @@ class ManifestForm extends PureComponent {
|
||||
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) {
|
||||
this.setState((prev) => {
|
||||
const next = prev.lines.filter((line) => line.key !== key);
|
||||
@@ -424,6 +520,8 @@ class ManifestForm extends PureComponent {
|
||||
linesColor={cfg.linesColor}
|
||||
onLineChange={this.handleLineChange}
|
||||
onAddLine={this.handleAddLine}
|
||||
onAddArticleLine={this.handleAddArticleLine}
|
||||
onSearchArticle={this.handleOpenArticleSearchForLine}
|
||||
onRemoveLine={this.handleRemoveLine}
|
||||
onRemoveAllLines={this.handleRemoveAllLines}
|
||||
onSave={() => void this.handleSave()}
|
||||
@@ -496,6 +594,12 @@ class ManifestForm extends PureComponent {
|
||||
onClose={this.handleClosePdfDialog}
|
||||
onAbort={this.handleAbortParse}
|
||||
/>
|
||||
<ArticleSearchDialog
|
||||
open={this.state.articleSearchDialogOpen}
|
||||
line={this.state.articleSearchLine}
|
||||
onClose={this.handleCloseArticleSearch}
|
||||
onAssign={this.handleAssignArticleForLine}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import DeleteSweepOutlinedIcon from '@mui/icons-material/DeleteSweepOutlined';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { LINE_COLUMNS, hasArticleLines } from './manifestConfig';
|
||||
import ManifestFormLine from './ManifestFormLine';
|
||||
import ArticleSearch from '../article/ArticleSearch';
|
||||
|
||||
class ManifestLines extends PureComponent {
|
||||
render() {
|
||||
@@ -22,6 +23,7 @@ class ManifestLines extends PureComponent {
|
||||
linesColor,
|
||||
onLineChange,
|
||||
onAddLine,
|
||||
onAddArticleLine,
|
||||
onRemoveLine,
|
||||
onRemoveAllLines,
|
||||
columns = LINE_COLUMNS,
|
||||
@@ -117,14 +119,25 @@ class ManifestLines extends PureComponent {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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
|
||||
size="small"
|
||||
startIcon={<AddIcon />}
|
||||
disabled={saving}
|
||||
onClick={onAddLine}
|
||||
>
|
||||
Zeile hinzufügen
|
||||
{onAddArticleLine ? 'Leere Zeile' : 'Zeile hinzufügen'}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
|
||||
@@ -32,23 +32,46 @@ import {
|
||||
formatCategoryList,
|
||||
formatStock,
|
||||
getCategoryDepth,
|
||||
getCategoryBreadcrumbLabel,
|
||||
parseFilter,
|
||||
} from '../components/article/searchFormat';
|
||||
import { preserveSearchFocus } from '../components/article/searchFocus';
|
||||
import { artikelDetailPath } from '../components/article/articleRoute';
|
||||
import { buildCategoryPath } from '../components/category/categoryRoute';
|
||||
import withSearchParams from '../components/article/withSearchParams';
|
||||
import { setLastArticleSku, setLastSearchTerm, navigationAutomat } from '../automats';
|
||||
import {
|
||||
setLastArticleSku,
|
||||
setLastSearchTerm,
|
||||
setLastCategoryId,
|
||||
navigationAutomat,
|
||||
categoryAutomat,
|
||||
} from '../automats';
|
||||
import { BrandLabel } from '../components/brand';
|
||||
|
||||
const resultRowSx = {
|
||||
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.
|
||||
'& .MuiListItemText-root': { minWidth: 0 },
|
||||
'& .MuiListItemText-primary, & .MuiListItemText-secondary': {
|
||||
'& .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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -68,7 +91,7 @@ function ResultRowMeasureSample() {
|
||||
<ListItemText
|
||||
primary={'\u00A0'}
|
||||
secondary={
|
||||
<Typography component="span" variant="body2" sx={{ visibility: 'hidden' }} noWrap>
|
||||
<Typography component="span" variant="body2" sx={{ visibility: 'hidden', lineHeight: '20px', height: '20px' }} noWrap>
|
||||
|
||||
</Typography>
|
||||
}
|
||||
@@ -111,6 +134,28 @@ function initialSearchState(searchParams) {
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
state = initialSearchState(this.props.searchParams);
|
||||
paginationRef = createRef();
|
||||
@@ -154,8 +199,22 @@ class ArtikelSearchPage extends Component {
|
||||
this.unsubSearch = articleSearchAutomat.subscribe(this, (state) => state.result);
|
||||
|
||||
// Initial search: kicks off index loading if idle and derives current items
|
||||
const { filter, offset } = queryFromParams(this.props.searchParams);
|
||||
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,
|
||||
@@ -213,6 +272,20 @@ class ArtikelSearchPage extends Component {
|
||||
});
|
||||
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,
|
||||
});
|
||||
@@ -275,16 +348,37 @@ class ArtikelSearchPage extends Component {
|
||||
const categories = parseFilter(filterParam);
|
||||
const rows = recordset ?? [];
|
||||
|
||||
const categoriesMap = categoryAutomat.state?.categories;
|
||||
const rootCategoryId = categoryAutomat.state?.rootCategoryId;
|
||||
const treeOrder = getCategoryTreeOrder(categoriesMap, rootCategoryId);
|
||||
|
||||
const facetEntries = 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 }]) => ({
|
||||
id,
|
||||
count,
|
||||
name,
|
||||
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 showPagination =
|
||||
@@ -338,11 +432,16 @@ class ArtikelSearchPage extends Component {
|
||||
</Button>
|
||||
)}
|
||||
{categories.map((catId) => {
|
||||
const catName = categoryFacets?.[catId]?.name ?? `Kategorie ${catId}`;
|
||||
const label = getCategoryBreadcrumbLabel(
|
||||
catId,
|
||||
categoriesMap,
|
||||
rootCategoryId,
|
||||
categoryFacets,
|
||||
);
|
||||
return (
|
||||
<Chip
|
||||
key={catId}
|
||||
label={catName}
|
||||
label={label}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
onDelete={() => {
|
||||
@@ -482,7 +581,14 @@ class ArtikelSearchPage extends Component {
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, maxWidth: '100%' }}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
maxWidth: '100%',
|
||||
lineHeight: '20px',
|
||||
height: '20px',
|
||||
}}
|
||||
>
|
||||
{(row.brand?.name || row.brandName) && (
|
||||
<>
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import categoryAutomat from '../../automats/categoryAutomat';
|
||||
import posterminalAutomat from '../../automats/posterminalAutomat';
|
||||
import companyAutomat from '../../automats/companyAutomat';
|
||||
import { setLastArticleSku, setLastCategorySlug } from '../../automats';
|
||||
import { setLastArticleSku, setLastCategorySlug, setLastCategoryId } from '../../automats';
|
||||
import {
|
||||
categoryDisplayName,
|
||||
getCategoryBreadcrumbs,
|
||||
@@ -225,8 +225,13 @@ class ArtikelKategorienPage extends PureComponent {
|
||||
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) {
|
||||
navigate(buildCategoryPath(categories, rootCategoryId, resolvedId), { replace: true });
|
||||
navigate(fullCatPath, { replace: true });
|
||||
}
|
||||
|
||||
const nextHandle = categoryAutomat.key(categorySlugPath ?? '');
|
||||
|
||||
@@ -97,6 +97,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
this.handleExternalNumberChange = this.handleExternalNumberChange.bind(this);
|
||||
this.handleLineChange = this.handleLineChange.bind(this);
|
||||
this.handleAddLine = this.handleAddLine.bind(this);
|
||||
this.handleAddArticleLine = this.handleAddArticleLine.bind(this);
|
||||
this.handleRemoveLine = this.handleRemoveLine.bind(this);
|
||||
this.handleRemoveAllLines = this.handleRemoveAllLines.bind(this);
|
||||
this.handleSave = this.handleSave.bind(this);
|
||||
@@ -506,6 +507,35 @@ class LieferungNeuPage extends PureComponent {
|
||||
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) {
|
||||
const index = this.findLineIndex(key);
|
||||
this.automat.actions.removeLineitem(index >= 0 ? index : key);
|
||||
@@ -1307,6 +1337,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
onLineChange={this.handleLineChange}
|
||||
onSkuBlur={this.handleSkuBlur}
|
||||
onAddLine={this.handleAddLine}
|
||||
onAddArticleLine={this.handleAddArticleLine}
|
||||
onRemoveLine={this.handleRemoveLine}
|
||||
onRemoveAllLines={this.handleRemoveAllLines}
|
||||
onSave={() => void this.handleSave()}
|
||||
|
||||
Reference in New Issue
Block a user