From 8c5419c89e4de3f0d8416b8b9256db2c2344d99a Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 5 Oct 2026 10:33:18 +0200 Subject: [PATCH] u --- src/automats/articleSearchAutomat.js | 287 +++++- src/automats/index.js | 7 +- src/automats/navigationAutomat.js | 67 +- src/components/article/ArticleSearch.jsx | 878 ++++++++++++++++++ .../article/boxes/ArticleViewLists.jsx | 41 +- src/components/article/index.js | 1 + src/components/article/searchFormat.js | 53 +- src/components/brand/BrandLabel.jsx | 2 + src/components/brand/BrandThumb.jsx | 3 + .../manifest/ArticleSearchDialog.jsx | 305 +----- src/components/manifest/ManifestForm.jsx | 104 +++ src/components/manifest/ManifestLines.jsx | 17 +- src/routes/artikel.jsx | 128 ++- src/routes/artikel/kategorien.jsx | 9 +- src/routes/lieferung/neu.jsx | 31 + 15 files changed, 1613 insertions(+), 320 deletions(-) create mode 100644 src/components/article/ArticleSearch.jsx diff --git a/src/automats/articleSearchAutomat.js b/src/automats/articleSearchAutomat.js index 8f4cc21..8c183b1 100644 --- a/src/automats/articleSearchAutomat.js +++ b/src/automats/articleSearchAutomat.js @@ -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} articles List of articles to sort. + * @param {string} query Search term. + * @returns {Array} 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} [options.excludeSkus] SKUs to exclude from results. + * @param {boolean} [options.allowParent=false] Whether to include parent articles. + * @param {Array} [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} 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} selectedCategories Array of category ID strings. + * @param {Record} categoriesMap Map of id -> categoryNode. + * @param {Record} [facets] Optional fallback facet map. + * @returns {Set} 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} selectedCategories Array of category ID strings. + * @param {Set} 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, diff --git a/src/automats/index.js b/src/automats/index.js index 31c4e17..47b4e1c 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -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. diff --git a/src/automats/navigationAutomat.js b/src/automats/navigationAutomat.js index 4a2e93e..26e6ef5 100644 --- a/src/automats/navigationAutomat.js +++ b/src/automats/navigationAutomat.js @@ -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, diff --git a/src/components/article/ArticleSearch.jsx b/src/components/article/ArticleSearch.jsx new file mode 100644 index 0000000..1aa6267 --- /dev/null +++ b/src/components/article/ArticleSearch.jsx @@ -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 ( + + + + + +   + + } + /> + + + ); +} + +/** + * 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} [props.excludeSkus=[]] SKUs to exclude. + * @param {boolean} [props.allowParent=false] Whether parent articles are selectable. + * @param {Array} [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 ( + { + 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: ( + + + + ), + endAdornment: value ? ( + + { + e.stopPropagation(); + this.handleSelectArticle(null); + }} + edge="end" + > + + + + ) : 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 ( + + {error && ( + + {error} + + )} + + + { + 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: ( + + + + ), + endAdornment: query ? ( + + { + this.setState({ query: '', offset: 0 }); + this.runQuery('', 0); + }} + edge="end" + > + + + + ) : null, + }, + }} + /> + + + + + + + } + minHeight={180} + sx={{ overflow: 'auto', flex: 1, minHeight: 0 }} + > + {loading ? ( + + + + Artikel werden geladen… + + + ) : results.length === 0 ? null : ( + + {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 ( + this.handleSelectArticle(article)} + sx={searchResultRowSx} + > + + + {hasPhoto ? ( + + ) : ( + + )} + + + + + {(article.brand?.name || article.brandName) && ( + <> + + {' · '} + + )} + {article.sku} + {article.stock != null && ` · Bestand ${formatStock(article.stock)}`} + {article.available != null && ` · Verfügbar ${formatStock(article.available)}`} + {primaryEan ? ` · EAN ${primaryEan}` : ''} + + } + slotProps={{ primary: { fontWeight: 500, noWrap: true } }} + /> + + + {price != null && ( + + {formatPrice(price)} + + )} + + + + ); + })} + + )} + + + ); + } + + renderDialogModal(isControlled) { + const { + open: controlledOpen, + title = this.props.label || 'Artikel suchen', + } = this.props; + const { internalOpen } = this.state; + const isDialogOpen = isControlled ? Boolean(controlledOpen) : internalOpen; + + return ( + + + + {title} + + + + + + + {this.renderListContent()} + + + + + + ); + } + + 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 ( + { + 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 ( + + + {hasPhoto ? ( + + ) : ( + + )} + + + + + + + {article.name || '–'} + + + + {(article.brand?.name || article.brandName) && ( + + )} + {article.stock != null && ( + + Bestand: {formatStock(article.stock)} + + )} + + + + {price != null && ( + + {formatPrice(price)} + + )} + + ); + }} + renderInput={(params) => { + const inputProps = params.InputProps ?? params.slotProps?.input ?? {}; + const startAdornment = ( + <> + + + + {inputProps.startAdornment} + + ); + const endAdornment = ( + <> + {loading ? : null} + {inputProps.endAdornment} + + ); + + if (params.InputProps) { + return ( + + ); + } + + return ( + + ); + }} + 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)} + + ); + } +} diff --git a/src/components/article/boxes/ArticleViewLists.jsx b/src/components/article/boxes/ArticleViewLists.jsx index e6bd8ab..d1a1893 100644 --- a/src/components/article/boxes/ArticleViewLists.jsx +++ b/src/components/article/boxes/ArticleViewLists.jsx @@ -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 ( @@ -663,23 +665,14 @@ export default class ArticleViewLists extends PureComponent { {/* Add Component Row */} - this.setState({ stuecklisteTargetArticle: val })} - getOptionLabel={(opt) => - typeof opt === 'string' ? opt : (opt ? `${opt.sku} - ${opt.name || ''}` : '') - } - isOptionEqualToValue={(opt, val) => opt?.sku === val?.sku} - renderInput={(params) => ( - - )} + 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 ( @@ -860,23 +854,14 @@ export default class ArticleViewLists extends PureComponent { {/* Add Option Row */} - this.setState({ optionTargetArticle: val })} - getOptionLabel={(opt) => - typeof opt === 'string' ? opt : (opt ? `${opt.sku} - ${opt.name || ''}` : '') - } - isOptionEqualToValue={(opt, val) => opt?.sku === val?.sku} - renderInput={(params) => ( - - )} + excludeSkus={[article?.sku, ...optionsItems.map((item) => item.component_sku || item.sku)]} + onSelect={(val) => this.setState({ optionTargetArticle: val })} sx={{ flex: 1, minWidth: 0 }} /> diff --git a/src/components/article/index.js b/src/components/article/index.js index 7169ba8..6707d78 100644 --- a/src/components/article/index.js +++ b/src/components/article/index.js @@ -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'; diff --git a/src/components/article/searchFormat.js b/src/components/article/searchFormat.js index 98912ba..b4f2967 100644 --- a/src/components/article/searchFormat.js +++ b/src/components/article/searchFormat.js @@ -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 diff --git a/src/components/brand/BrandLabel.jsx b/src/components/brand/BrandLabel.jsx index d1c8978..6fa6538 100644 --- a/src/components/brand/BrandLabel.jsx +++ b/src/components/brand/BrandLabel.jsx @@ -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', diff --git a/src/components/brand/BrandThumb.jsx b/src/components/brand/BrandThumb.jsx index 0ac385c..6a20225 100644 --- a/src/components/brand/BrandThumb.jsx +++ b/src/components/brand/BrandThumb.jsx @@ -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, diff --git a/src/components/manifest/ArticleSearchDialog.jsx b/src/components/manifest/ArticleSearchDialog.jsx index f1134f5..4926cea 100644 --- a/src/components/manifest/ArticleSearchDialog.jsx +++ b/src/components/manifest/ArticleSearchDialog.jsx @@ -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 ( @@ -163,183 +111,32 @@ export default class ArticleSearchDialog extends PureComponent { - + {error && ( - + {error} )} - - - - ), - endAdornment: query ? ( - - - - - - ) : null, - }, - }} - sx={{ mb: 1.5 }} + initialQuery={initialQuery} + allowParent={false} + busySku={assigningSku} + actionLabel={assigningSku ? 'Zuordnen…' : 'Zuordnen'} + onSelect={this.handleSelect} /> - - {loading ? ( - - - - Artikel werden geladen… - - - ) : filtered.length === 0 ? ( - - - Keine Artikel für „{query}“ gefunden. - - - ) : ( - - {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 ( - this.handleSelect(article)} - sx={{ - py: 1, - px: 1.5, - '&:hover': { - bgcolor: 'action.hover', - }, - }} - > - - - {hasPhoto ? ( - - ) : ( - - )} - - - - - - {article.name || '–'} - - - } - secondary={ - - {(article.brand?.name || article.brandName) && ( - - )} - {primaryEan ? ( - - EAN: {primaryEan} - - ) : null} - - } - /> - - - ); - })} - - )} diff --git a/src/components/manifest/ManifestForm.jsx b/src/components/manifest/ManifestForm.jsx index 3f43537..9ba3394 100644 --- a/src/components/manifest/ManifestForm.jsx +++ b/src/components/manifest/ManifestForm.jsx @@ -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} /> + ); } diff --git a/src/components/manifest/ManifestLines.jsx b/src/components/manifest/ManifestLines.jsx index 93611d6..261ed77 100644 --- a/src/components/manifest/ManifestLines.jsx +++ b/src/components/manifest/ManifestLines.jsx @@ -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 { - + + {onAddArticleLine && ( + + )} )} {categories.map((catId) => { - const catName = categoryFacets?.[catId]?.name ?? `Kategorie ${catId}`; + const label = getCategoryBreadcrumbLabel( + catId, + categoriesMap, + rootCategoryId, + categoryFacets, + ); return ( { @@ -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) && ( <> diff --git a/src/routes/artikel/kategorien.jsx b/src/routes/artikel/kategorien.jsx index 632271f..176f21e 100644 --- a/src/routes/artikel/kategorien.jsx +++ b/src/routes/artikel/kategorien.jsx @@ -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 ?? ''); diff --git a/src/routes/lieferung/neu.jsx b/src/routes/lieferung/neu.jsx index 970a6b5..c5296eb 100644 --- a/src/routes/lieferung/neu.jsx +++ b/src/routes/lieferung/neu.jsx @@ -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()}