From e399019001a23ee95636f7843bfa8fbf1e4dbaaf Mon Sep 17 00:00:00 2001 From: Carsten Graf Date: Thu, 19 Mar 2026 15:39:52 +0100 Subject: [PATCH] =?UTF-8?q?Englische=20Texte,=20Kopiericon,=20Get-Route=20?= =?UTF-8?q?f=C3=BCr=20suche?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/dev.html | 59 ++++++++++++++++++++++++++- public/index.html | 85 +++++++++++++++++++++++++++++++++++++-- server.js | 37 +++++++++++++++++ services/searchService.js | 29 ++++++++++++- 4 files changed, 205 insertions(+), 5 deletions(-) diff --git a/public/dev.html b/public/dev.html index 7d530c0..95ec069 100644 --- a/public/dev.html +++ b/public/dev.html @@ -216,6 +216,7 @@ margin: 0 -15px; padding-left: 15px; padding-right: 15px; + align-items: flex-start; } .record-body .record-field:nth-child(even) { background: rgba(0,0,0,0.04); @@ -235,6 +236,23 @@ word-break: break-word; white-space: pre-line; margin-left: 16px; + flex: 1; + } + + .copy-btn { + margin-left: 8px; + border: none; + background: transparent; + color: #666; + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 2px 4px; + border-radius: 4px; + } + .copy-btn:hover { + background: rgba(0, 0, 0, 0.08); + color: #222; } .price-age { @@ -390,6 +408,17 @@ searchButton.addEventListener('click', performSearch); resultsDiv.addEventListener('click', (e) => { + const copyBtn = e.target.closest('.copy-btn'); + if (copyBtn) { + e.preventDefault(); + const copyText = copyBtn.getAttribute('data-copy') || ''; + copyToClipboard(copyText); + const original = copyBtn.textContent; + copyBtn.textContent = '✓'; + setTimeout(() => { copyBtn.textContent = original; }, 700); + return; + } + const link = e.target.closest('.part-link'); if (!link) return; e.preventDefault(); @@ -485,6 +514,8 @@ { key: 'Teil', label: 'Teilenummer' }, { key: 'Bez', label: 'Bezeichnung' }, { key: 'Bez2', label: 'Bezeichnung 2' }, + { key: 'EngBez1', label: 'Englische Bezeichnung 1' }, + { key: 'EngBez2', label: 'Englische Bezeichnung 2' }, { key: 'Ben8', label: 'Bezeichnung 3' }, { key: 'Ben7', label: 'Englischer Text' }, { key: 'Hersteller', label: 'Hersteller' }, @@ -533,40 +564,50 @@ if (!(key in record)) continue; const value = record[key]; let displayValue; + let copyValue = ''; if (key === 'Ersatz' && value != null && String(value).trim() !== '') { const partNum = String(value).trim(); const safePart = escapeHtml(partNum); const safePartAttr = safePart.replace(/"/g, '"'); displayValue = `${safePart}`; + copyValue = partNum; } else if (key === 'PrsVK') { const isVkTeil = record.VkTeil != null && Number(record.VkTeil) !== 0; if (isVkTeil && value != null && String(value).trim() !== '') { + const valueText = String(value); const priceText = highlightSearchTerm(String(value)); const priceTermStr = record.PrsVkTerm || record.PrsVKTerm; let ageHtml = ''; + let ageText = ''; if (priceTermStr) { const priceDate = new Date(priceTermStr); if (!isNaN(priceDate)) { const now = new Date(); const diffMs = now.getTime() - priceDate.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - const ageText = `${diffDays} Tage alt`; + ageText = `${diffDays} Tage alt`; const isOld = diffDays > 182; // > ca. 6 Monate const ageClass = isOld ? 'price-age price-age--old' : 'price-age'; ageHtml = ` (${escapeHtml(ageText)})`; } } displayValue = `${priceText}${ageHtml}`; + copyValue = ageText ? `${valueText} (${ageText})` : valueText; } else { displayValue = escapeHtml('kein Verkaufsteil'); + copyValue = 'kein Verkaufsteil'; } } else { + const plainValue = value === null || value === undefined ? '' : String(value); displayValue = value === null || value === undefined ? '' : highlightSearchTerm(String(value)); + copyValue = plainValue; } + const safeCopyAttr = escapeHtml(copyValue).replace(/"/g, '"'); html += `
${escapeHtml(label)}: ${displayValue} +
`; } html += ` @@ -606,6 +647,22 @@ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + async function copyToClipboard(text) { + try { + await navigator.clipboard.writeText(text); + } catch (err) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } + } + // Health Check beim Laden window.addEventListener('load', async () => { try { diff --git a/public/index.html b/public/index.html index 7d530c0..dfd0b09 100644 --- a/public/index.html +++ b/public/index.html @@ -216,6 +216,7 @@ margin: 0 -15px; padding-left: 15px; padding-right: 15px; + align-items: flex-start; } .record-body .record-field:nth-child(even) { background: rgba(0,0,0,0.04); @@ -235,6 +236,23 @@ word-break: break-word; white-space: pre-line; margin-left: 16px; + flex: 1; + } + + .copy-btn { + margin-left: 8px; + border: none; + background: transparent; + color: #666; + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 2px 4px; + border-radius: 4px; + } + .copy-btn:hover { + background: rgba(0, 0, 0, 0.08); + color: #222; } .price-age { @@ -390,6 +408,17 @@ searchButton.addEventListener('click', performSearch); resultsDiv.addEventListener('click', (e) => { + const copyBtn = e.target.closest('.copy-btn'); + if (copyBtn) { + e.preventDefault(); + const copyText = copyBtn.getAttribute('data-copy') || ''; + copyToClipboard(copyText); + const original = copyBtn.textContent; + copyBtn.textContent = '✓'; + setTimeout(() => { copyBtn.textContent = original; }, 700); + return; + } + const link = e.target.closest('.part-link'); if (!link) return; e.preventDefault(); @@ -484,9 +513,11 @@ const DISPLAY_COLUMNS = [ { key: 'Teil', label: 'Teilenummer' }, { key: 'Bez', label: 'Bezeichnung' }, - { key: 'Bez2', label: 'Bezeichnung 2' }, + { key: 'Bez2', label: 'Bezeichnung 2' }, { key: 'Ben8', label: 'Bezeichnung 3' }, - { key: 'Ben7', label: 'Englischer Text' }, + { key: 'EngBez1', label: 'Englischer Text 1' }, + { key: 'EngBez2', label: 'Englischer Text 2' }, + { key: 'Ben7', label: 'Englischer Text 3' }, { key: 'Hersteller', label: 'Hersteller' }, { key: 'Text', label: 'Zusatztext' }, { key: 'PrsVK', label: 'VK in €' }, @@ -533,40 +564,50 @@ if (!(key in record)) continue; const value = record[key]; let displayValue; + let copyValue = ''; if (key === 'Ersatz' && value != null && String(value).trim() !== '') { const partNum = String(value).trim(); const safePart = escapeHtml(partNum); const safePartAttr = safePart.replace(/"/g, '"'); displayValue = `${safePart}`; + copyValue = partNum; } else if (key === 'PrsVK') { const isVkTeil = record.VkTeil != null && Number(record.VkTeil) !== 0; if (isVkTeil && value != null && String(value).trim() !== '') { + const valueText = String(value); const priceText = highlightSearchTerm(String(value)); const priceTermStr = record.PrsVkTerm || record.PrsVKTerm; let ageHtml = ''; + let ageText = ''; if (priceTermStr) { const priceDate = new Date(priceTermStr); if (!isNaN(priceDate)) { const now = new Date(); const diffMs = now.getTime() - priceDate.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - const ageText = `${diffDays} Tage alt`; + ageText = `${diffDays} Tage alt`; const isOld = diffDays > 182; // > ca. 6 Monate const ageClass = isOld ? 'price-age price-age--old' : 'price-age'; ageHtml = ` (${escapeHtml(ageText)})`; } } displayValue = `${priceText}${ageHtml}`; + copyValue = ageText ? `${valueText} (${ageText})` : valueText; } else { displayValue = escapeHtml('kein Verkaufsteil'); + copyValue = 'kein Verkaufsteil'; } } else { + const plainValue = value === null || value === undefined ? '' : String(value); displayValue = value === null || value === undefined ? '' : highlightSearchTerm(String(value)); + copyValue = plainValue; } + const safeCopyAttr = escapeHtml(copyValue).replace(/"/g, '"'); html += `
${escapeHtml(label)}: ${displayValue} +
`; } html += ` @@ -606,8 +647,42 @@ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + async function copyToClipboard(text) { + try { + await navigator.clipboard.writeText(text); + } catch (err) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } + } + + // Suchbegriff/Status aus URL übernehmen (z.B. /?q=abc&status=aktiv) + function applySearchParamsFromUrl() { + const params = new URLSearchParams(window.location.search); + const initialSearch = (params.get('q') || params.get('search') || '').trim(); + const initialStatus = (params.get('status') || '').trim(); + + if (initialSearch) { + searchInput.value = initialSearch; + } + + if (['aktiv', 'pruefbar', 'inaktiv', ''].includes(initialStatus)) { + statusFilterSelect.value = initialStatus; + } + + return initialSearch; + } + // Health Check beim Laden window.addEventListener('load', async () => { + const initialSearch = applySearchParamsFromUrl(); try { const response = await fetch('/api/health'); const data = await response.json(); @@ -617,6 +692,10 @@ } catch (error) { console.error('Health check failed:', error); } + + if (initialSearch) { + await performSearch(); + } }); diff --git a/server.js b/server.js index 4198468..a23a885 100644 --- a/server.js +++ b/server.js @@ -21,6 +21,43 @@ app.use(express.static(path.join(__dirname, 'public'))); // API Routes app.use('/api/search', searchRoutes); +// Direkte URL-Aufrufe auf die UI umleiten und Suchbegriff vorbelegen: +// - /search=meinbegriff -> /?q=meinbegriff +// - /search?q=meinbegriff oder /search?search=meinbegriff -> /?q=meinbegriff +app.get('/search=:searchTerm', (req, res) => { + const searchTerm = (req.params.searchTerm || '').trim(); + const statusFilter = (req.query.status || '').trim(); + + if (!searchTerm) { + return res.redirect('/'); + } + + const redirectUrl = new URL(`http://localhost:${PORT}/`); + redirectUrl.searchParams.set('q', searchTerm); + if (statusFilter) { + redirectUrl.searchParams.set('status', statusFilter); + } + + return res.redirect(`${redirectUrl.pathname}${redirectUrl.search}`); +}); + +app.get('/search', (req, res) => { + const searchTerm = (req.query.search || req.query.q || '').trim(); + const statusFilter = (req.query.status || '').trim(); + + if (!searchTerm) { + return res.redirect('/'); + } + + const redirectUrl = new URL(`http://localhost:${PORT}/`); + redirectUrl.searchParams.set('q', searchTerm); + if (statusFilter) { + redirectUrl.searchParams.set('status', statusFilter); + } + + return res.redirect(`${redirectUrl.pathname}${redirectUrl.search}`); +}); + // Health check endpoint app.get('/api/health', async (req, res) => { try { diff --git a/services/searchService.js b/services/searchService.js index 1ed8b2b..8b0f625 100644 --- a/services/searchService.js +++ b/services/searchService.js @@ -27,6 +27,8 @@ const TEILE_OUTPUT_COLUMNS = [ 'Teil', 'Bez', 'Bez2', + 'EngBez1', + 'EngBez2', 'Ben7', 'Ben8', 'Hersteller', @@ -98,7 +100,14 @@ const fullTextSearch = async (searchTerm, statusFilter) => { WHERE ek.[Teil] = t.Teil AND tx.[Text] LIKE @${paramName} COLLATE SQL_Latin1_General_CP1_CI_AS )`; - return `(${teileCond} OR ${textCond})`; + const tnbCond = `EXISTS ( + SELECT 1 + FROM TEXTE1 tx1 WITH (NOLOCK) + WHERE tx1.Id = N'TNB' + AND tx1.Schluessel = t.Teil + AND tx1.[Text] LIKE @${paramName} COLLATE SQL_Latin1_General_CP1_CI_AS + )`; + return `(${teileCond} OR ${textCond} OR ${tnbCond})`; }); let whereClause = wordConditions.join(' AND '); @@ -116,6 +125,8 @@ const fullTextSearch = async (searchTerm, statusFilter) => { t.Teil, t.Bez, t.Bez2, + en.EngBez1, + en.EngBez2, t.Ben7, t.Ben8, t.Ben43, @@ -127,6 +138,22 @@ const fullTextSearch = async (searchTerm, statusFilter) => { ts.Ersatz AS Ersatz, ts.PrsVkTerm FROM [${tableConfig.tableName}] t WITH (NOLOCK) + OUTER APPLY ( + SELECT + LEFT(src.FullText, 30) AS EngBez1, + CASE + WHEN LEN(src.FullText) > 30 THEN SUBSTRING(src.FullText, 31, 30) + ELSE N'' + END AS EngBez2 + FROM ( + SELECT TOP 1 + ISNULL(tx1.[Text], N'') AS FullText + FROM TEXTE1 tx1 WITH (NOLOCK) + WHERE tx1.Id = N'TNB' + AND tx1.Schluessel = t.Teil + ORDER BY ISNULL(tx1.Zeile, 32767), tx1.ISN + ) src + ) en OUTER APPLY ( SELECT TOP 1 MemoID FROM EKATSS WITH (NOLOCK)