From dcfadb51d3aafef4a223acd8a16bcd25ba250c99 Mon Sep 17 00:00:00 2001 From: Carsten Graf Date: Wed, 11 Mar 2026 17:24:14 +0100 Subject: [PATCH] =?UTF-8?q?=C3=84nderungen=20in=20der=20Suche?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Spalten.txt | 2 +- mssql-mcp-readonly.js | 9 +++- public/dev.html | 96 ++++++++++++++++++++++++++++++++++----- routes/search.js | 3 +- services/searchService.js | 51 ++++++++++++++++----- 5 files changed, 136 insertions(+), 25 deletions(-) diff --git a/Spalten.txt b/Spalten.txt index 89bd03a..48c4fdb 100644 --- a/Spalten.txt +++ b/Spalten.txt @@ -4,7 +4,7 @@ Bez2 Ben7 (Englischer Text) Ben8 (Deutscher Text) Hersteller -Text -> Text aus der tabelle TEXT durch TextId verknüpft +Text -> MemoID aus EKATSS (EKATSS.TEIL = Teil), dann TEXT.TextId = MemoID PrsVK -> Aus Tabelle TSSAEF verknüpft durch Teil Ersatz (Ersetzte durch) -> Aus Tabelle TSSAEF, Spalte Ersatz diff --git a/mssql-mcp-readonly.js b/mssql-mcp-readonly.js index 69c3cfe..e5d265b 100644 --- a/mssql-mcp-readonly.js +++ b/mssql-mcp-readonly.js @@ -208,14 +208,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { t.Ben8, t.Hersteller, t.Stat, + t.VkTeil, txt.Text AS [Text], ts.PrsVk AS PrsVK, ts.Ersatz AS Ersatz FROM TEILE t WITH (NOLOCK) + OUTER APPLY ( + SELECT TOP 1 MemoID + FROM EKATSS WITH (NOLOCK) + WHERE [Teil] = t.Teil + ORDER BY (SELECT NULL) + ) ek OUTER APPLY ( SELECT TOP 1 [Text] FROM TEXT WITH (NOLOCK) - WHERE TextId = t.MemoID + WHERE TextId = ek.MemoID ORDER BY LfdNr ) txt OUTER APPLY ( diff --git a/public/dev.html b/public/dev.html index 6fc790a..3af04c5 100644 --- a/public/dev.html +++ b/public/dev.html @@ -15,7 +15,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: #0066FF; min-height: 100vh; padding: 20px; } @@ -70,6 +70,21 @@ border-color: #667eea; } + .search-filter { + padding: 15px 12px; + font-size: 16px; + border: 2px solid #e0e0e0; + border-radius: 8px; + background: white; + color: #333; + cursor: pointer; + min-width: 140px; + } + .search-filter:focus { + outline: none; + border-color: #667eea; + } + .search-button { padding: 15px 40px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); @@ -198,6 +213,15 @@ .record-field { display: flex; padding: 5px 0; + margin: 0 -15px; + padding-left: 15px; + padding-right: 15px; + } + .record-body .record-field:nth-child(even) { + background: rgba(0,0,0,0.04); + } + .record-body .record-field:nth-child(odd) { + background: transparent; } .field-name { @@ -209,6 +233,16 @@ .field-value { color: #333; word-break: break-word; + white-space: pre-line; + } + + .field-value .part-link { + color: #1565c0; + text-decoration: none; + font-weight: 600; + } + .field-value .part-link:hover { + text-decoration: underline; } .highlight { @@ -301,6 +335,12 @@ placeholder="Suchbegriff eingeben..." autofocus > + @@ -310,10 +350,6 @@
0
Treffer
-
-
0
-
Tabellen durchsucht
-
0ms
Suchzeit
@@ -329,6 +365,7 @@ const searchButton = document.getElementById('searchButton'); const resultsDiv = document.getElementById('results'); const statsDiv = document.getElementById('stats'); + const statusFilterSelect = document.getElementById('statusFilter'); let currentSearchTerm = ''; // Enter-Taste zum Suchen @@ -340,6 +377,19 @@ searchButton.addEventListener('click', performSearch); + resultsDiv.addEventListener('click', (e) => { + const link = e.target.closest('.part-link'); + if (!link) return; + e.preventDefault(); + const part = link.getAttribute('data-part'); + if (part) { + searchInput.value = part; + // Beim Klick auf "Ersetzt durch" immer auf "Alle" setzen + statusFilterSelect.value = ''; + performSearch(); + } + }); + async function performSearch() { const searchTerm = searchInput.value.trim(); @@ -357,7 +407,11 @@ const startTime = performance.now(); try { - const response = await fetch(`/api/search?q=${encodeURIComponent(searchTerm)}`); + const statusFilter = statusFilterSelect.value; + const url = new URL('/api/search', window.location.origin); + url.searchParams.set('q', searchTerm); + if (statusFilter) url.searchParams.set('status', statusFilter); + const response = await fetch(url.toString()); const data = await response.json(); const endTime = performance.now(); @@ -380,7 +434,6 @@ // Statistiken anzeigen statsDiv.style.display = 'flex'; document.getElementById('totalMatches').textContent = data.totalMatches; - document.getElementById('tablesSearched').textContent = data.tablesSearched; document.getElementById('searchTime').textContent = searchTime + 'ms'; // Ergebnisse anzeigen @@ -405,7 +458,7 @@ ${tableResult.matchCount} Treffer
- ${tableResult.records.map(record => renderRecord(record)).join('')} + ${tableResult.records.slice().sort((a, b) => getStatusSortOrder(a.Stat) - getStatusSortOrder(b.Stat)).map(record => renderRecord(record)).join('')}
`; @@ -420,8 +473,8 @@ { key: 'Teil', label: 'Teilenummer' }, { key: 'Bez', label: 'Bezeichnung' }, { key: 'Bez2', label: 'Bezeichnung 2' }, - { key: 'Ben7', label: 'Englischer Text' }, - { key: 'Ben8', label: 'Deutscher Text' }, + { key: 'Ben8', label: 'Bezeichnung 3' }, + { key: 'Ben7', label: 'Eng 3' }, { key: 'Hersteller', label: 'Hersteller' }, { key: 'Text', label: 'Zusatztext' }, { key: 'PrsVK', label: 'Verkaufspreis in €' }, @@ -437,6 +490,14 @@ return 'record--aktiv'; // "" oder leer = aktiv } + /** Sortierreihenfolge: 0 = aktiv (zuerst), 1 = prüfbar, 2 = Rest */ + function getStatusSortOrder(stat) { + const s = (stat == null ? '' : String(stat).trim()); + if (s === '') return 0; // aktiv + if (s === 'L') return 1; // prüfbar + return 2; // inaktiv, löschbar, pseudoteil + } + function getStatusLabel(stat) { const s = (stat == null ? '' : String(stat).trim()); if (s === 'i') return 'Inaktiv'; @@ -458,7 +519,20 @@ for (const { key, label } of DISPLAY_COLUMNS) { if (!(key in record)) continue; const value = record[key]; - const displayValue = value === null || value === undefined ? '' : highlightSearchTerm(String(value)); + let displayValue; + if (key === 'Ersatz' && value != null && String(value).trim() !== '') { + const partNum = String(value).trim(); + const safePart = escapeHtml(partNum); + const safePartAttr = safePart.replace(/"/g, '"'); + displayValue = `${safePart}`; + } else if (key === 'PrsVK') { + const isVkTeil = record.VkTeil != null && Number(record.VkTeil) !== 0; + displayValue = isVkTeil && value != null && String(value).trim() !== '' + ? highlightSearchTerm(String(value)) + : escapeHtml('kein Verkaufsteil'); + } else { + displayValue = value === null || value === undefined ? '' : highlightSearchTerm(String(value)); + } html += `
${escapeHtml(label)}: diff --git a/routes/search.js b/routes/search.js index 7306548..0a3330a 100644 --- a/routes/search.js +++ b/routes/search.js @@ -9,6 +9,7 @@ const { fullTextSearch, getAvailableTables, getTableColumns, SEARCH_CONFIG } = r router.get('/', async (req, res) => { try { const searchTerm = req.query.q; + const statusFilter = req.query.status; // '' | 'aktiv' | 'pruefbar' | 'inaktiv' if (!searchTerm) { return res.status(400).json({ @@ -17,7 +18,7 @@ router.get('/', async (req, res) => { }); } - const results = await fullTextSearch(searchTerm); + const results = await fullTextSearch(searchTerm, statusFilter); const totalMatches = results.reduce((sum, table) => sum + table.matchCount, 0); const tableErrors = results.filter(table => table && table.error); diff --git a/services/searchService.js b/services/searchService.js index a18f15c..e3bbc7d 100644 --- a/services/searchService.js +++ b/services/searchService.js @@ -21,7 +21,7 @@ const TEILE_SEARCH_COLUMNS = [ ]; // Ausgabe-Spalten für Suchergebnisse (siehe Spalten.txt) -// Text aus TEXT (TEILE.MemoID = TEXT.TextId), PrsVK + Ersatz aus TSSAEF (Teil = Teil) +// Text aus TEXT: MemoID aus EKATSS (EKATSS.TEIL = Teil), dann TEXT.TextId = MemoID; PrsVK + Ersatz aus TSSAEF (Teil = Teil) const TEILE_OUTPUT_COLUMNS = [ 'Teil', 'Bez', @@ -68,9 +68,10 @@ const buildSingleWordCondition = (columns, paramName, tableAlias = '') => { * - Optimierte Query-Struktur mit TOP für bessere Performance * * @param {string} searchTerm - Der zu suchende Begriff (mehrere Wörter möglich, getrennt durch Leerzeichen) + * @param {string} [statusFilter] - Optional: 'aktiv' | 'pruefbar' | 'inaktiv' (leer/undefined = alle) * @returns {Promise} Array mit Suchergebnissen */ -const fullTextSearch = async (searchTerm) => { +const fullTextSearch = async (searchTerm, statusFilter) => { if (!searchTerm || searchTerm.trim() === '') { throw new Error('Suchbegriff darf nicht leer sein'); } @@ -82,16 +83,32 @@ const fullTextSearch = async (searchTerm) => { const pool = await getConnection(); - // TEILE: Feste 8 Spalten mit JOINs auf TEXT (MemoID=TextId) und TSSAEF (Teil) + // TEILE: Ausgabe-Spalten mit JOINs auf EKATSS (MemoID), TEXT (TextId=MemoID), TSSAEF (Teil) const searchPromises = SEARCH_CONFIG.map(async (tableConfig) => { try { - // Pro Wort: (Spalte1 LIKE @searchWord0 OR ... OR SpalteN LIKE @searchWord0), alle mit AND verknüpft + // Pro Wort: Treffer in TEILE-Spalten ODER im Zusatztext (TEXT über EKATSS); alle Wörter mit AND const wordConditions = words.map((_, i) => { const paramName = `searchWord${i}`; - const cond = buildSingleWordCondition(tableConfig.columns, paramName, 't'); - return `(${cond})`; + const teileCond = buildSingleWordCondition(tableConfig.columns, paramName, 't'); + const textCond = `EXISTS ( + SELECT 1 + FROM EKATSS ek WITH (NOLOCK) + INNER JOIN [TEXT] tx WITH (NOLOCK) ON tx.TextId = ek.MemoID + WHERE ek.[Teil] = t.Teil + AND tx.[Text] LIKE @${paramName} COLLATE SQL_Latin1_General_CP1_CI_AS + )`; + return `(${teileCond} OR ${textCond})`; }); - const whereClause = wordConditions.join(' AND '); + let whereClause = wordConditions.join(' AND '); + + // Status-Filter: aktiv = leer/NULL, pruefbar = L, inaktiv = i + if (statusFilter === 'aktiv') { + whereClause += ` AND (t.Stat IS NULL OR LTRIM(RTRIM(ISNULL(t.Stat, N''))) = N'')`; + } else if (statusFilter === 'pruefbar') { + whereClause += ` AND t.Stat = @statusFilter`; + } else if (statusFilter === 'inaktiv') { + whereClause += ` AND t.Stat = @statusFilter`; + } const query = ` SELECT TOP 100 @@ -102,15 +119,22 @@ const fullTextSearch = async (searchTerm) => { t.Ben8, t.Hersteller, t.Stat, + t.VkTeil, txt.Text AS [Text], ts.PrsVk AS PrsVK, ts.Ersatz AS Ersatz FROM [${tableConfig.tableName}] t WITH (NOLOCK) OUTER APPLY ( - SELECT TOP 1 [Text] - FROM [TEXT] WITH (NOLOCK) - WHERE TextId = COALESCE(NULLIF(RTRIM(LTRIM(t.MemoID)), N''), N'MEM-' + t.Teil) - ORDER BY LfdNr + SELECT TOP 1 MemoID + FROM EKATSS WITH (NOLOCK) + WHERE [Teil] = t.Teil + ORDER BY (SELECT NULL) + ) ek + OUTER APPLY ( + SELECT + STRING_AGG(tx.[Text], CHAR(10)) WITHIN GROUP (ORDER BY tx.LfdNr) AS [Text] + FROM [TEXT] tx WITH (NOLOCK) + WHERE tx.TextId = ek.MemoID ) txt OUTER APPLY ( SELECT TOP 1 PrsVk, Ersatz @@ -125,6 +149,11 @@ const fullTextSearch = async (searchTerm) => { words.forEach((word, i) => { request.input(`searchWord${i}`, sql.NVarChar, `%${word}%`); }); + if (statusFilter === 'pruefbar') { + request.input('statusFilter', sql.NVarChar(10), 'L'); + } else if (statusFilter === 'inaktiv') { + request.input('statusFilter', sql.NVarChar(10), 'i'); + } const result = await request.query(query);