(() => { function normalizeSearchText(value, options = {}) { const caseSensitive = Boolean(options.caseSensitive); const collapseWhitespace = Boolean(options.collapseWhitespace); let text = String(value ?? ''); // Preserve the common German transliterations before Unicode accent // folding so searches treat umlauts and their ASCII spellings equally. text = text .replace(/Ä/g, 'Ae') .replace(/Ö/g, 'Oe') .replace(/Ü/g, 'Ue') .replace(/ä/g, 'ae') .replace(/ö/g, 'oe') .replace(/ü/g, 'ue') .replace(/ẞ/g, 'SS') .replace(/ß/g, 'ss') .normalize('NFKD') .replace(/[\u0300-\u036f]/g, ''); if (collapseWhitespace) text = text.trim().replace(/\s+/g, ' '); if (!caseSensitive) text = text.toLocaleLowerCase(); return text; } // Standalone filters and duplicate detection use the exact same comparison // rules as the generic table filters. window.AssetManagerNormalizeSearchText = normalizeSearchText; const collator = new Intl.Collator(document.documentElement.lang || 'de', { numeric: true, sensitivity: 'base' }); function controlValue(control) { if (control.tagName === 'SELECT') { const selected = control.options?.[control.selectedIndex]; return [ control.value || '', selected?.textContent || '' ].join(' '); } if (control.type === 'checkbox' || control.type === 'radio') { return control.checked ? [ control.value || '', control.getAttribute('aria-label') || '', control.closest('label')?.textContent || '' ].join(' ') : ''; } return [ control.value || '', control.getAttribute('placeholder') || '', control.getAttribute('aria-label') || '' ].join(' '); } function textOutsideControls(cell) { const clone = cell.cloneNode(true); clone.querySelectorAll('input, select, textarea, option').forEach(node => node.remove()); return (clone.textContent || '').trim(); } function textValue(cell) { if (!cell) return ''; const explicitFilterValue = cell.dataset.filterValue || ''; if (cell.dataset.sortValue !== undefined) { return `${cell.dataset.sortValue} ${explicitFilterValue}`.trim(); } const controls = [...cell.querySelectorAll('input, select, textarea')]; if (controls.length) { const controlText = controls.map(controlValue).join(' ').trim(); const additionalText = textOutsideControls(cell); return `${explicitFilterValue} ${controlText} ${additionalText}`.trim(); } return `${explicitFilterValue} ${cell.textContent || ''}`.trim(); } function parsedValue(value) { const text = value.trim(); if (!text) return { type: 'empty', value: '' }; if (/^\d{4}-\d{2}-\d{2}(?:[ T].*)?$/.test(text)) { const timestamp = Date.parse(text.replace(' ', 'T')); if (!Number.isNaN(timestamp)) return { type: 'date', value: timestamp }; } if (/^\d{1,2}\.\d{1,2}\.\d{4}$/.test(text)) { const [day, month, year] = text.split('.').map(Number); return { type: 'date', value: new Date(year, month - 1, day).getTime() }; } if (/^(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}$/.test(text)) { return { type: 'ipv4', value: text.split('.').map(Number) }; } const numeric = text.replace(/\s/g, '').replace(',', '.'); if (/^[-+]?\d+(?:\.\d+)?$/.test(numeric)) { return { type: 'number', value: Number(numeric) }; } return { type: 'text', value: text }; } function compareValues(a, b) { const left = parsedValue(a); const right = parsedValue(b); if (left.type === 'empty' && right.type !== 'empty') return 1; if (right.type === 'empty' && left.type !== 'empty') return -1; if (left.type === right.type && (left.type === 'number' || left.type === 'date')) { return left.value - right.value; } if (left.type === 'ipv4' && right.type === 'ipv4') { for (let index = 0; index < 4; index += 1) { const difference = left.value[index] - right.value[index]; if (difference) return difference; } return 0; } return collator.compare(String(left.value), String(right.value)); } function initTable(table) { const thead = table.tHead; const tbody = table.tBodies[0]; if (!thead || !tbody || !thead.rows.length) return; if (table.dataset.tableToolsReady === '1') return; const headerRow = thead.rows[0]; const isAssetTable = table.id === 'asset-table'; const usesServerFilters = table.dataset.serverFilters === '1'; let serverFilterTimer = null; // Normal tables get stable synthetic keys. The asset table already has // real data-field keys on its movable columns; selection and action // columns must remain without a field key. if (!isAssetTable) { [...headerRow.cells].forEach((header, index) => { if (!header.dataset.field) { header.dataset.field = `__col_${index}`; header.dataset.syntheticField = '1'; } }); [...tbody.rows].forEach(row => { [...row.cells].forEach((cell, index) => { const header = headerRow.cells[index]; if (header && !cell.dataset.field) { cell.dataset.field = header.dataset.field; cell.dataset.syntheticField = '1'; } }); }); } const filterRow = document.createElement('tr'); filterRow.className = 'column-filter-row'; filterRow.dataset.tableFilterRow = '1'; const filterDefinitions = []; function filterStatusTarget() { const wrapper = table.closest( '#asset-table-scroll, .management-table-scroll, .table-scroll, .table-wrap, .sticky-table' ); return wrapper || table; } function ensureFilterStatus() { let status = table._filterStatusElement; if (status?.isConnected) return status; status = document.createElement('div'); status.className = 'table-filter-status'; status.hidden = true; status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite'); const icon = document.createElement('span'); icon.className = 'table-filter-status-icon'; icon.textContent = '⌕'; icon.setAttribute('aria-hidden', 'true'); const label = document.createElement('strong'); label.className = 'table-filter-status-label'; const details = document.createElement('span'); details.className = 'table-filter-status-details'; const clearButton = document.createElement('button'); clearButton.type = 'button'; clearButton.className = 'button button-secondary table-filter-clear'; clearButton.textContent = document.body.dataset.clearFiltersLabel || 'Filter zurücksetzen'; clearButton.addEventListener('click', () => { filterDefinitions.forEach(definition => { definition.input.value = ''; }); applyFilters(); if (usesServerFilters) submitServerFilters(true); // Keep the persisted browser state in sync with the visible table. // Programmatic value changes do not trigger input/change automatically. window.dispatchEvent(new CustomEvent('asset-table-filters-cleared', { detail: { table } })); filterDefinitions[0]?.input?.focus(); }); status.append(icon, label, details, clearButton); const target = filterStatusTarget(); target.parentNode?.insertBefore(status, target); table._filterStatusElement = status; return status; } function updateFilterPresentation(active, visibleRows, totalRows) { filterDefinitions.forEach(definition => { const hasValue = Boolean(definition.input.value.trim()); definition.input.classList.toggle('filter-input-active', hasValue); definition.input.closest('th')?.classList.toggle('filter-cell-active', hasValue); }); const status = ensureFilterStatus(); const label = status.querySelector('.table-filter-status-label'); const details = status.querySelector('.table-filter-status-details'); const activeCount = active.length; status.hidden = activeCount === 0; status.classList.toggle('is-active', activeCount > 0); if (activeCount > 0) { const activeTemplate = document.body.dataset.filtersActiveTemplate || '{count} Filter aktiv'; const statusTemplate = document.body.dataset.filterStatusTemplate || 'Gefiltert: {visible} von {total} Datensätzen'; label.textContent = activeTemplate.replace('{count}', String(activeCount)); details.textContent = statusTemplate .replace('{visible}', String(visibleRows)) .replace('{total}', String(totalRows)); } } function cellForDefinition(row, definition) { if (definition.field) { return row.querySelector(`td[data-field="${CSS.escape(definition.field)}"]`); } return row.cells[definition.columnIndex] || null; } function rebuildMissingCellKeys() { if (isAssetTable) return; [...tbody.rows].forEach(row => { [...row.cells].forEach((cell, index) => { const header = headerRow.cells[index]; if (header && !cell.dataset.field) { cell.dataset.field = header.dataset.field; } }); }); } function numericFilterMatches(rawValue, rawTerm) { const value = Number(String(rawValue).trim().replace(',', '.')); if (!Number.isFinite(value)) return false; const term = String(rawTerm || '').trim().toLocaleLowerCase(); if (!term) return true; const normalize = input => input.replace(',', '.').trim(); let match = term.match(/^(<=|>=|<|>|=)\s*(-?\d+(?:[.,]\d+)?)$/); if (match) { const expected = Number(normalize(match[2])); if (match[1] === '<') return value < expected; if (match[1] === '<=') return value <= expected; if (match[1] === '>') return value > expected; if (match[1] === '>=') return value >= expected; return value === expected; } match = term.match(/^(-?\d+(?:[.,]\d+)?)\s*(?:\.\.|-)\s*(-?\d+(?:[.,]\d+)?)$/); if (!match) { match = term.match(/^(?:zwischen|between)\s+(-?\d+(?:[.,]\d+)?)\s+(?:und|and)\s+(-?\d+(?:[.,]\d+)?)$/); } if (match) { const first = Number(normalize(match[1])); const second = Number(normalize(match[2])); const minimum = Math.min(first, second); const maximum = Math.max(first, second); return value >= minimum && value <= maximum; } const exact = Number(normalize(term)); return Number.isFinite(exact) ? value === exact : false; } function submitServerFilters(immediate = false) { if (!usesServerFilters) return; if (serverFilterTimer) window.clearTimeout(serverFilterTimer); const navigate = () => { const target = new URL( table.dataset.serverFilterUrl || window.location.pathname, window.location.origin ); const current = new URL(window.location.href); const limit = current.searchParams.get('job_limit'); if (limit) target.searchParams.set('job_limit', limit); filterDefinitions.forEach(definition => { if (!definition.serverName) return; const value = definition.input.value.trim(); if (value) target.searchParams.set(`job_filter_${definition.serverName}`, value); }); try { window.sessionStorage.setItem( 'assetmanager:software-jobs:last-server-filter', document.activeElement?.name || '' ); } catch (_) {} window.location.assign(target.toString()); }; if (immediate) navigate(); else serverFilterTimer = window.setTimeout(navigate, 700); } function applyFilters() { rebuildMissingCellKeys(); const active = filterDefinitions .map(definition => ({ definition, term: normalizeSearchText(definition.input?.value.trim() || '') })) .filter(item => item.term); if (usesServerFilters) { const serverTotal = Number.parseInt(table.dataset.serverFilterTotal || '', 10); const total = Number.isFinite(serverTotal) ? serverTotal : tbody.rows.length; updateFilterPresentation(active, tbody.rows.length, total); window.dispatchEvent(new CustomEvent('asset-table-filter-changed', { detail: { table, visible: tbody.rows.length, total, activeFilters: active.length } })); return; } let visibleRows = 0; [...tbody.rows].forEach(row => { const hidden = active.some(({ definition, term }) => { const cell = cellForDefinition(row, definition); const value = textValue(cell); if (definition.numericFilter) { return !numericFilterMatches(value, term); } return !normalizeSearchText(value).includes(term); }); row.hidden = hidden; if (!hidden) visibleRows += 1; }); updateFilterPresentation(active, visibleRows, tbody.rows.length); window.dispatchEvent(new CustomEvent('asset-table-filter-changed', { detail: { table, visible: visibleRows, total: tbody.rows.length, activeFilters: active.length } })); } [...headerRow.cells].forEach((header, columnIndex) => { const field = header.dataset.field || ''; const serverName = header.dataset.serverFilterName || ''; if (!header.dataset.noSort) { header.classList.add('sortable-column'); header.title = header.dataset.sortTitle || 'Zum Sortieren anklicken'; header.addEventListener('click', event => { if (event.target.closest('input,button,a,select,textarea,label')) return; const direction = header.dataset.sortDirection === 'asc' ? 'desc' : 'asc'; [...headerRow.cells].forEach(item => { delete item.dataset.sortDirection; item.classList.remove('sort-asc', 'sort-desc'); }); header.dataset.sortDirection = direction; header.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc'); const rows = [...tbody.rows]; rows.sort((left, right) => { const definition = { field, columnIndex }; const result = compareValues( textValue(cellForDefinition(left, definition)), textValue(cellForDefinition(right, definition)) ); return direction === 'asc' ? result : -result; }); const fragment = document.createDocumentFragment(); rows.forEach(row => fragment.appendChild(row)); tbody.appendChild(fragment); applyFilters(); }); } const filterCell = document.createElement('th'); if (field) filterCell.dataset.field = field; filterCell.dataset.columnIndex = String(columnIndex); if (!header.dataset.noFilter && (!usesServerFilters || serverName)) { const input = document.createElement('input'); input.type = 'search'; input.placeholder = header.dataset.filterPlaceholder || 'Filtern …'; input.setAttribute('aria-label', `Filter ${textValue(header)}`); input.autocomplete = 'off'; if (usesServerFilters) { input.name = `job_filter_${serverName}`; input.value = header.dataset.serverFilterValue || ''; input.addEventListener('input', () => { applyFilters(); submitServerFilters(false); }); input.addEventListener('search', () => { applyFilters(); submitServerFilters(false); }); input.addEventListener('keydown', event => { if (event.key !== 'Enter') return; event.preventDefault(); submitServerFilters(true); }); } else { input.addEventListener('input', applyFilters); input.addEventListener('search', applyFilters); } filterCell.appendChild(input); filterDefinitions.push({ field, columnIndex, input, serverName, numericFilter: header.dataset.numericFilter === '1' }); } filterRow.appendChild(filterCell); }); thead.appendChild(filterRow); // Editable cells are always read live, so changed values immediately // participate in filtering without a stale cache. tbody.addEventListener('input', event => { if (event.target.matches('input,select,textarea')) applyFilters(); }); tbody.addEventListener('change', event => { if (event.target.matches('input,select,textarea')) applyFilters(); }); table.assetApplyFilters = applyFilters; table.assetRebuildFilterCache = rebuildMissingCellKeys; table.dataset.tableToolsReady = '1'; ensureFilterStatus(); // Covers browser-restored values after reload/back navigation. requestAnimationFrame(() => { applyFilters(); if (usesServerFilters) { try { const lastName = window.sessionStorage.getItem('assetmanager:software-jobs:last-server-filter'); const lastInput = lastName ? filterDefinitions.find(definition => definition.input.name === lastName)?.input : null; if (lastInput) { lastInput.focus(); lastInput.setSelectionRange(lastInput.value.length, lastInput.value.length); } window.sessionStorage.removeItem('assetmanager:software-jobs:last-server-filter'); } catch (_) {} } }); window.dispatchEvent(new CustomEvent('asset-table-structure-ready', { detail: { table } })); } document.querySelectorAll('table.data-table').forEach(initTable); window.addEventListener('asset-table-content-reloaded', event => { const table = event.detail?.table; if (table?.assetRebuildFilterCache) { table.assetRebuildFilterCache(); table.assetApplyFilters?.(); } }); })();