diff --git a/package-lock.json b/package-lock.json index 14295d1..0bd0660 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "ag-grid-community": "^36.2.0", "ag-grid-react": "^36.2.0", "dashgui": "file:../dashgui", + "flexsearch": "^0.8.212", "jsbarcode": "^3.12.3", "notistack": "^3.0.2", "pdfconvert-wasm": "^0.3.0", @@ -92,23 +93,23 @@ "ag-grid-react": "^36.2.0", "marked": "^18.0.14", "prismjs": "^1.30.0", - "react": "^19.2.8", + "react": "^19.3.0", "react-automat": "file:../automat", - "react-dom": "^19.2.8" + "react-dom": "^19.3.0" }, "devDependencies": { "@eslint/js": "^10.0.1", - "@types/node": "^24.13.3", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.7", - "@vitejs/plugin-react": "^6.1.1", - "eslint": "^10.10.0", + "@types/node": "^26.6.4", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.2", + "eslint": "^10.12.0", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.6", - "globals": "^17.12.0", - "typescript": "~6.0.2", - "typescript-eslint": "^8.69.0", - "vite": "^8.3.0" + "eslint-plugin-react-refresh": "^0.5.7", + "globals": "^17.13.0", + "typescript": "~7.0.2", + "typescript-eslint": "^8.71.0", + "vite": "^8.3.2" } }, "node_modules/@babel/code-frame": { @@ -5277,6 +5278,34 @@ "dev": true, "license": "ISC" }, + "node_modules/flexsearch": { + "version": "0.8.212", + "resolved": "https://registry.npmjs.org/flexsearch/-/flexsearch-0.8.212.tgz", + "integrity": "sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/ts-thomas" + }, + { + "type": "paypal", + "url": "https://www.paypal.com/donate/?hosted_button_id=GEVR88FC9BWRW" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/flexsearch" + }, + { + "type": "patreon", + "url": "https://patreon.com/user?u=96245532" + }, + { + "type": "liberapay", + "url": "https://liberapay.com/ts-thomas" + } + ], + "license": "Apache-2.0" + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", diff --git a/package.json b/package.json index e99dfde..994f046 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "ag-grid-community": "^36.2.0", "ag-grid-react": "^36.2.0", "dashgui": "file:../dashgui", + "flexsearch": "^0.8.212", "jsbarcode": "^3.12.3", "notistack": "^3.0.2", "pdfconvert-wasm": "^0.3.0", @@ -40,7 +41,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@vitejs/plugin-react": "6.1.1", + "@vitejs/plugin-react": "6.1.2", "eslint": "^10.12.0", "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.13.0", diff --git a/src/automats/articleSearchAutomat.js b/src/automats/articleSearchAutomat.js index 8c183b1..29c53b1 100644 --- a/src/automats/articleSearchAutomat.js +++ b/src/automats/articleSearchAutomat.js @@ -11,6 +11,7 @@ */ import { Automat } from 'react-automat'; +import FlexSearch from 'flexsearch'; import articleIndexAutomat from './articleIndexAutomat'; import categoryAutomat from './categoryAutomat'; import { parseFilter } from '../components/article/searchFormat'; @@ -61,28 +62,101 @@ const initialState = { let articleSearchAutomat; +const { Document, Charset } = FlexSearch; + +let flexDocument = null; +let lastIndexedArticles = null; +let articleByInternalId = new Map(); + /** - * Matches one index record against a search query (checks tokens in name, sku, brand, eans). + * Lazily constructs and maintains the FlexSearch Document index for the given articles array. * - * @param {object} row Article index record. - * @param {string} query Lowercased search term. - * @returns {boolean} Whether the record matches. + * @param {Array} articles Current article index items. + * @returns {{ doc: any, byId: Map }} */ -function matches(row, query) { - if (!query) return true; - const tokens = query.split(/\s+/).filter(Boolean); - if (tokens.length === 0) return true; - const name = String(row.name ?? '').toLowerCase(); - const sku = String(row.sku ?? '').toLowerCase(); - const brand = String(row.brand?.name || row.brandName || '').toLowerCase(); - const eans = (row.eans ?? []).map((e) => String(e).toLowerCase()); - return tokens.every( - (token) => - name.includes(token) || - sku.includes(token) || - brand.includes(token) || - eans.some((ean) => ean.includes(token)), - ); +function getOrCreateFlexIndex(articles) { + if (flexDocument && lastIndexedArticles === articles) { + return { doc: flexDocument, byId: articleByInternalId }; + } + + const doc = new Document({ + document: { + id: 'id', + index: [ + { field: 'all', tokenize: 'forward', resolution: 9 }, + { field: 'sku', tokenize: 'full', resolution: 9 }, + { field: 'name', tokenize: 'forward', resolution: 9 }, + { field: 'brand', tokenize: 'forward', resolution: 5 }, + { field: 'eans', tokenize: 'forward', resolution: 9 }, + ], + }, + tokenize: 'forward', + charset: Charset.LatinAdvanced, + }); + + const byId = new Map(); + for (let i = 0; i < articles.length; i++) { + const article = articles[i]; + const docId = i; + byId.set(docId, article); + const sku = String(article.sku ?? ''); + const name = String(article.name ?? ''); + const brand = String(article.brand?.name || article.brandName || ''); + const eans = (article.eans ?? []).join(' '); + const all = `${sku} ${name} ${brand} ${eans}`.trim(); + + doc.add({ + id: docId, + sku, + name, + brand, + eans, + all, + }); + } + + flexDocument = doc; + lastIndexedArticles = articles; + articleByInternalId = byId; + return { doc, byId }; +} + +/** + * Searches the FlexSearch index with the query string. + * + * @param {string} query Search query. + * @param {Array} articles Full articles collection. + * @returns {Array} Articles matching the search query. + */ +function queryFlexSearch(query, articles) { + const q = String(query ?? '').trim(); + if (!q) return articles; + + const { doc, byId } = getOrCreateFlexIndex(articles); + let rawResults; + try { + rawResults = doc.search(q); + } catch { + rawResults = []; + } + + const matchedIds = new Set(); + for (const fieldResult of rawResults) { + if (Array.isArray(fieldResult.result)) { + for (const id of fieldResult.result) { + matchedIds.add(id); + } + } + } + + const matchesList = []; + for (const id of matchedIds) { + const art = byId.get(id); + if (art) { + matchesList.push(art); + } + } + return matchesList; } /** @@ -180,11 +254,12 @@ export function searchArticlesIndex(query, options = {}) { } const articles = articleIndexAutomat.state.items ?? []; - const q = String(query ?? '').trim().toLowerCase(); + const q = String(query ?? '').trim(); + const rawMatches = q ? queryFlexSearch(q, articles) : articles; const excludedSet = new Set((excludeSkus ?? []).map((s) => String(s).toLowerCase())); - let pool = articles; + let pool = rawMatches; if (!allowParent) { pool = pool.filter((a) => !a.isParent && !a.is_parent); } @@ -192,7 +267,7 @@ export function searchArticlesIndex(query, options = {}) { pool = pool.filter((a) => a.sku && !excludedSet.has(String(a.sku).toLowerCase())); } - let matchesList = q ? pool.filter((row) => matches(row, q)) : pool; + let matchesList = pool; if (categories && categories.length > 0) { const categoriesMap = categoryAutomat.state?.categories ?? {}; @@ -390,11 +465,11 @@ const actions = { const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset; const articles = articleIndexAutomat.state.items ?? []; - const query = target.trim().toLowerCase(); + const query = target.trim(); - // Filter by text query across all tokens + // Query candidates using FlexSearch const textMatches = query - ? articles.filter((row) => matches(row, query)) + ? queryFlexSearch(query, articles) : articles; // Build facets for all text matches (before category filtering) @@ -506,8 +581,11 @@ const actions = { return actions.runSearch({ offset }); }, - /** Reset the search state. */ + /** Reset the search state and clear FlexSearch index. */ reset() { + flexDocument = null; + lastIndexedArticles = null; + articleByInternalId = new Map(); articleSearchAutomat.setState(initialState); }, }; diff --git a/src/components/manifest/ArticleSearchDialog.jsx b/src/components/manifest/ArticleSearchDialog.jsx index 033a799..864a5f9 100644 --- a/src/components/manifest/ArticleSearchDialog.jsx +++ b/src/components/manifest/ArticleSearchDialog.jsx @@ -27,7 +27,8 @@ export default class ArticleSearchDialog extends PureComponent { } handleSelect = async (article) => { - const { onAssign, line } = this.props; + const { onAssign, line, kind = 'delivery' } = this.props; + const isDelivery = kind === 'delivery'; if (!onAssign || !article?.sku) return; if (article.isParent || article.is_parent) { @@ -41,14 +42,19 @@ export default class ArticleSearchDialog extends PureComponent { } catch (err) { this.setState({ assigningSku: null, - error: err?.message || 'Fehler beim Verknüpfen des Lieferanten-Artikels.', + error: + err?.message || + (isDelivery + ? 'Fehler beim Verknüpfen des Lieferanten-Artikels.' + : 'Fehler beim Zuordnen des Artikels.'), }); } }; render() { - const { open, onClose, line } = this.props; + const { open, onClose, line, kind = 'delivery' } = this.props; const { assigningSku, error } = this.state; + const isDelivery = kind === 'delivery'; const rawSku = line?.sku != null ? String(line.sku).trim() : ''; return ( @@ -93,10 +99,10 @@ export default class ArticleSearchDialog extends PureComponent { > - Existierenden Artikel zuordnen + {line ? 'Existierenden Artikel zuordnen' : 'Artikel auswählen'} - Lieferanten-SKU: {rawSku || '–'} + {isDelivery ? 'Lieferanten-SKU' : 'SKU'}: {rawSku || '–'} {line?.name ? ` · ${line.name}` : ''} diff --git a/src/components/manifest/ManifestForm.jsx b/src/components/manifest/ManifestForm.jsx index 9ba3394..30da787 100644 --- a/src/components/manifest/ManifestForm.jsx +++ b/src/components/manifest/ManifestForm.jsx @@ -13,6 +13,7 @@ import ManifestHeader from './ManifestHeader'; import ManifestLines from './ManifestLines'; import ManifestPdfDialog from './ManifestPdfDialog'; import ArticleSearchDialog from './ArticleSearchDialog'; +import artikelAutomat, { getArticle } from '../../automats/artikelAutomat'; import { buildManifestBody, restoreAttachmentsFromApi, @@ -55,6 +56,7 @@ class ManifestForm extends PureComponent { this.handleExternalNumberChange = this.handleExternalNumberChange.bind(this); this.handleLineChange = this.handleLineChange.bind(this); + this.handleSkuBlur = this.handleSkuBlur.bind(this); this.handleAddLine = this.handleAddLine.bind(this); this.handleAddArticleLine = this.handleAddArticleLine.bind(this); this.handleOpenArticleSearchForLine = this.handleOpenArticleSearchForLine.bind(this); @@ -208,6 +210,47 @@ class ManifestForm extends PureComponent { })); } + async handleSkuBlur(key, sku) { + const trimmed = String(sku ?? '').trim(); + if (!trimmed) return; + try { + const art = getArticle(trimmed) || (await artikelAutomat.actions.loadArticle(trimmed)); + if (art && !this._cancelled) { + this.setState((prev) => ({ + lines: prev.lines.map((l) => { + if (l.key !== key) return l; + const netVal = + art.price != null + ? String(art.price) + : art.vknetto != null + ? String(art.vknetto) + : art.net != null + ? String(art.net) + : ''; + const taxVal = + art.taxrate != null + ? String(art.taxrate) + : art.tax != null + ? String(art.tax) + : ''; + return { + ...l, + sku: art.sku || l.sku, + name: art.name || l.name || '', + tax: l.tax || taxVal, + taxrate: l.taxrate || taxVal, + net: l.net || netVal, + netprice: l.netprice || netVal, + unit: art.unit || l.unit || 'Stück', + }; + }), + })); + } + } catch { + // not in catalog or load error; preserve user entry + } + } + handleAddLine() { this.setState((prev) => ({ lines: [...prev.lines, emptyLine()] })); } @@ -266,7 +309,15 @@ class ManifestForm extends PureComponent { } handleAssignArticleForLine(article, targetLine) { - if (!article || !targetLine) return; + if (!article) return; + if (!targetLine) { + this.handleAddArticleLine(article); + this.setState({ + articleSearchDialogOpen: false, + articleSearchLine: null, + }); + return; + } const netVal = article.price != null ? String(article.price) @@ -513,12 +564,14 @@ class ManifestForm extends PureComponent { return ( diff --git a/src/components/manifest/ManifestFormLine.jsx b/src/components/manifest/ManifestFormLine.jsx index 8eeb126..bf666fe 100644 --- a/src/components/manifest/ManifestFormLine.jsx +++ b/src/components/manifest/ManifestFormLine.jsx @@ -31,9 +31,10 @@ import { class ManifestFormLine extends PureComponent { constructor(props) { super(props); - const sku = props.supplierArticle?.article?.sku; + const sku = this.getArticleSku(props); this.state = { cachedArticle: sku ? getArticle(sku) : null, + loadingArticle: false, }; this.handleRemove = this.handleRemove.bind(this); this.handleBlur = this.handleBlur.bind(this); @@ -42,13 +43,20 @@ class ManifestFormLine extends PureComponent { this.handleRemoveSupplierArticle = this.handleRemoveSupplierArticle.bind(this); } + getArticleSku(props = this.props) { + if (props.kind === 'delivery') { + return props.supplierArticle?.article?.sku || null; + } + return props.line?.sku ? String(props.line.sku).trim() : null; + } + componentDidMount() { - this.subscribeArticle(this.props.supplierArticle?.article?.sku); + this.subscribeArticle(this.getArticleSku()); } componentDidUpdate(prevProps) { - const prevSku = prevProps.supplierArticle?.article?.sku; - const nextSku = this.props.supplierArticle?.article?.sku; + const prevSku = this.getArticleSku(prevProps); + const nextSku = this.getArticleSku(this.props); if (prevSku !== nextSku) { this.subscribeArticle(nextSku); } @@ -62,19 +70,35 @@ class ManifestFormLine extends PureComponent { this.unsubArticle?.(); this.unsubArticle = null; if (!sku) { - if (this.state.cachedArticle) { - this.setState({ cachedArticle: null }); + if (this.state.cachedArticle || this.state.loadingArticle) { + this.setState({ cachedArticle: null, loadingArticle: false }); } return; } const current = getArticle(sku); - this.setState({ cachedArticle: current }); - if (!current) { - artikelAutomat.actions.loadArticle(sku).catch(() => { }); + if (current) { + this.setState({ cachedArticle: current, loadingArticle: false }); + } else { + this.setState({ cachedArticle: null, loadingArticle: true }); + artikelAutomat.actions.loadArticle(sku) + .then((art) => { + if (this.getArticleSku() === sku) { + this.setState({ cachedArticle: art, loadingArticle: false }); + } + }) + .catch(() => { + if (this.getArticleSku() === sku) { + this.setState({ cachedArticle: null, loadingArticle: false }); + } + }); } - this.unsubArticle = artikelAutomat.subscribe(this, (state) => ({ - cachedArticle: createArticleSelector(sku)(state), - })); + this.unsubArticle = artikelAutomat.subscribe(this, (state) => { + const art = createArticleSelector(sku)(state); + return { + cachedArticle: art ?? null, + ...(art ? { loadingArticle: false } : {}), + }; + }); } handleRemove() { @@ -251,8 +275,255 @@ class ManifestFormLine extends PureComponent { } renderSecondLine() { + const { kind = 'delivery' } = this.props; + const rawSku = this.props.line?.sku != null ? String(this.props.line.sku).trim() : ''; + + if (kind === 'delivery') { + return this.renderDeliverySecondLine(rawSku); + } + return this.renderArticleSecondLine(rawSku); + } + + renderArticleSecondLine(rawSku) { + const { loading } = this.props; + const { cachedArticle, loadingArticle } = this.state; + + if (loading || loadingArticle) { + return ( + + + + Artikeldaten werden geladen… + + + ); + } + + if (cachedArticle) { + const photo = cachedArticle.photos?.[0] || cachedArticle.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 eans = cachedArticle.eans ?? cachedArticle.articleean ?? []; + const primaryEan = Array.isArray(eans) ? eans[0] : eans; + + const vkPrice = primaryPrice(cachedArticle); + const vkNetto = vkPrice?.net; + const vkBrutto = vkPrice + ? priceBrutto(vkPrice) ?? + (vkPrice.net != null && vkPrice.taxrate != null + ? vkPrice.net * (1 + vkPrice.taxrate / 100) + : null) + : null; + + return ( + + {/* Article Photo Thumbnail */} + + {hasPhoto ? ( + + ) : ( + + )} + + + {/* Internal Article SKU */} + {cachedArticle.sku && ( + + )} + + {/* Brand */} + {cachedArticle.brand?.name && ( + + )} + + {/* Article Name */} + {cachedArticle.name && ( + + {cachedArticle.name} + + )} + + {/* Primary EAN */} + {primaryEan && ( + + EAN: {primaryEan} + + )} + + {/* VK Netto & VK Brutto */} + {(vkNetto != null || vkBrutto != null) && ( + + {vkNetto != null && ( + + VK Netto: {formatPrice(vkNetto)} + + )} + {vkBrutto != null && ( + + VK Brutto: {formatPrice(vkBrutto)} + + )} + + )} + + ); + } + + if (rawSku) { + return ( + + {this.props.onSearchArticle && ( + + )} + + + Kein Artikel für SKU „{rawSku}“ gefunden. + + + ); + } + + if (this.props.onSearchArticle) { + return ( + + + + ); + } + + return null; + } + + renderDeliverySecondLine(rawSku) { const { line, supplierArticle, loading } = this.props; - const rawSku = line?.sku != null ? String(line.sku).trim() : ''; if (loading) { return ( diff --git a/src/components/manifest/ManifestLines.jsx b/src/components/manifest/ManifestLines.jsx index 7321769..671dc30 100644 --- a/src/components/manifest/ManifestLines.jsx +++ b/src/components/manifest/ManifestLines.jsx @@ -9,6 +9,7 @@ import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import AddIcon from '@mui/icons-material/Add'; +import SearchIcon from '@mui/icons-material/Search'; import DeleteSweepOutlinedIcon from '@mui/icons-material/DeleteSweepOutlined'; import { PanelTile } from 'dashgui/panels'; import { LINE_COLUMNS, hasArticleLines } from './manifestConfig'; @@ -17,6 +18,7 @@ import ManifestFormLine from './ManifestFormLine'; class ManifestLines extends PureComponent { render() { const { + kind = 'delivery', lines, saving, linesColor, @@ -86,17 +88,21 @@ class ManifestLines extends PureComponent { lines.map((line, index) => { const lineKey = line.key ?? index; const rawSku = line.sku != null ? String(line.sku).trim() : ''; - const matched = - supplierArticlesByLineKey[lineKey] || - (rawSku ? supplierArticlesMap[rawSku.toLowerCase()] || supplierArticlesMap[rawSku] : null); - const isLineLoading = - loadingLineKeys[lineKey] || - (supplierArticlesLoading && !matched); + const isDelivery = kind === 'delivery'; + const matched = isDelivery + ? supplierArticlesByLineKey[lineKey] || + (rawSku ? supplierArticlesMap[rawSku.toLowerCase()] || supplierArticlesMap[rawSku] : null) + : null; + const isLineLoading = isDelivery + ? loadingLineKeys[lineKey] || + (supplierArticlesLoading && !matched) + : false; const ledgerLine = line.id != null ? ledgerLinesById[line.id] : null; return ( Zeile + {onSearchArticle && ( + + )}