(() => { function normalizeSearchText(value, options = {}) { const caseSensitive = Boolean(options.caseSensitive); const collapseWhitespace = Boolean(options.collapseWhitespace); let text = String(value ?? ''); if (collapseWhitespace) text = text.trim().replace(/\s+/g, ' '); if (!caseSensitive) text = text.toLocaleLowerCase(); const protectedGerman = text .replace(/ä/g, '\uE000') .replace(/ö/g, '\uE001') .replace(/ü/g, '\uE002') .replace(/ß/g, '\uE003'); return protectedGerman .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .replace(/\uE000/g, 'ä') .replace(/\uE001/g, 'ö') .replace(/\uE002/g, 'ü') .replace(/\uE003/g, 'ß'); } function germanSearchKey(value, options = {}) { let text = normalizeSearchText(value, options); // Mark only plausible ASCII umlaut spellings. The marker keeps a real or // transliterated umlaut distinct from natural letter pairs in words such // as "Bauer", "teuer" and "euer". text = text.replace(/(^|[^aeiouäöü])([aou])e/g, '$1$2~e'); return text .replace(/ä/g, 'a~e') .replace(/ö/g, 'o~e') .replace(/ü/g, 'u~e') .replace(/ß/g, 's~s'); } function searchTextMatches(value, term, options = {}) { const directValue = normalizeSearchText(value, options); const directTerm = normalizeSearchText(term, options); if (!directTerm) return true; if (directValue.includes(directTerm)) return true; return germanSearchKey(value, options).includes(germanSearchKey(term, options)); } // Standalone filters and duplicate detection use the exact same comparison // rules as the generic table filters. window.AssetManagerNormalizeSearchText = normalizeSearchText; window.AssetManagerGermanSearchKey = germanSearchKey; window.AssetManagerSearchTextMatches = searchTextMatches; 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]; // Add a purely visual row-number column before every real table column. // It is recalculated after filtering, sorting and partial server reloads and // therefore never represents a database identifier. if (!headerRow.querySelector('th[data-row-number-column="1"]')) { const numberHeader = document.createElement('th'); numberHeader.className = 'table-row-number-header'; numberHeader.dataset.rowNumberColumn = '1'; numberHeader.dataset.noSort = '1'; numberHeader.dataset.noFilter = '1'; numberHeader.textContent = '#'; numberHeader.title = document.body.dataset.rowNumberLabel || 'Row number'; headerRow.insertBefore(numberHeader, headerRow.firstChild); } const numberHeader = headerRow.querySelector('th[data-row-number-column="1"]'); const rowNumberStoragePart = table.dataset.storageKey || table.id || window.location.pathname; const rowNumberWidthKey = `assetmanager:table:${rowNumberStoragePart}:row-number-width`; const defaultRowNumberWidth = 64; const minimumRowNumberWidth = 48; const maximumRowNumberWidth = 180; function readStoredRowNumberWidth() { try { const stored = Number.parseInt(window.localStorage.getItem(rowNumberWidthKey) || '', 10); if (Number.isFinite(stored)) { return Math.min(maximumRowNumberWidth, Math.max(minimumRowNumberWidth, stored)); } } catch (_) {} return defaultRowNumberWidth; } function applyRowNumberWidth(width) { const normalized = Math.min(maximumRowNumberWidth, Math.max(minimumRowNumberWidth, Math.round(width))); table.style.setProperty('--table-row-number-width', `${normalized}px`); table.dataset.rowNumberWidth = String(normalized); return normalized; } function installRowNumberResizer() { if (!numberHeader || numberHeader.querySelector('.table-row-number-resizer')) return; const handle = document.createElement('span'); handle.className = 'table-row-number-resizer'; handle.setAttribute('role', 'separator'); handle.setAttribute('aria-orientation', 'vertical'); handle.tabIndex = 0; handle.title = document.body.dataset.rowNumberResizeLabel || 'Drag to change column width'; numberHeader.appendChild(handle); const saveWidth = width => { const normalized = applyRowNumberWidth(width); try { window.localStorage.setItem(rowNumberWidthKey, String(normalized)); } catch (_) {} }; handle.addEventListener('pointerdown', event => { event.preventDefault(); event.stopPropagation(); const startX = event.clientX; const startWidth = Number.parseInt(table.dataset.rowNumberWidth || '', 10) || defaultRowNumberWidth; handle.setPointerCapture?.(event.pointerId); document.documentElement.classList.add('table-column-resizing'); const move = moveEvent => applyRowNumberWidth(startWidth + moveEvent.clientX - startX); const finish = finishEvent => { saveWidth(Number.parseInt(table.dataset.rowNumberWidth || '', 10) || startWidth); handle.releasePointerCapture?.(finishEvent.pointerId); document.documentElement.classList.remove('table-column-resizing'); handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', finish); handle.removeEventListener('pointercancel', finish); }; handle.addEventListener('pointermove', move); handle.addEventListener('pointerup', finish); handle.addEventListener('pointercancel', finish); }); handle.addEventListener('keydown', event => { if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; event.preventDefault(); const current = Number.parseInt(table.dataset.rowNumberWidth || '', 10) || defaultRowNumberWidth; saveWidth(current + (event.key === 'ArrowRight' ? 8 : -8)); }); } applyRowNumberWidth(readStoredRowNumberWidth()); installRowNumberResizer(); // Every visible data column can be resized independently. Widths are // stored per table and per field/index so they survive navigation, // reloads and container updates. The visual row-number column keeps its // dedicated implementation above. const columnWidthStoragePrefix = `assetmanager:table:${rowNumberStoragePart}:column-width:`; const minimumColumnWidth = 56; const maximumColumnWidth = 1200; function columnStorageId(header, columnIndex) { const field = header?.dataset?.field; if (field) return field; return `index-${columnIndex}`; } function normalizeColumnWidth(width) { return Math.min(maximumColumnWidth, Math.max(minimumColumnWidth, Math.round(width))); } function applyColumnWidth(columnIndex, width) { const normalized = normalizeColumnWidth(width); const rows = [ ...thead.rows, ...tbody.rows ]; rows.forEach(row => { const cell = row.cells?.[columnIndex]; if (!cell || cell.dataset.rowNumberColumn === '1') return; cell.style.width = `${normalized}px`; cell.style.minWidth = `${normalized}px`; cell.style.maxWidth = `${normalized}px`; }); return normalized; } function readStoredColumnWidth(header, columnIndex) { try { const key = `${columnWidthStoragePrefix}${columnStorageId(header, columnIndex)}`; const stored = Number.parseInt(window.localStorage.getItem(key) || '', 10); if (Number.isFinite(stored)) return normalizeColumnWidth(stored); } catch (_) {} return null; } function saveColumnWidth(header, columnIndex, width) { const normalized = applyColumnWidth(columnIndex, width); try { const key = `${columnWidthStoragePrefix}${columnStorageId(header, columnIndex)}`; window.localStorage.setItem(key, String(normalized)); } catch (_) {} return normalized; } function freezeTableGeometry() { if (table.id === 'asset-table') return; const widths = [...headerRow.cells].map(cell => Math.max(minimumColumnWidth, Math.round(cell.getBoundingClientRect().width || minimumColumnWidth))); const total = widths.reduce((sum, width) => sum + width, 0); widths.forEach((width, columnIndex) => { if (headerRow.cells[columnIndex]?.dataset.rowNumberColumn === '1') return; applyColumnWidth(columnIndex, width); }); table.style.tableLayout = 'fixed'; table.style.width = `${total}px`; table.style.minWidth = `${total}px`; } function synchronizeTableWidth() { if (table.id === 'asset-table') return; const total = [...headerRow.cells].reduce((sum, cell) => sum + Math.round(cell.getBoundingClientRect().width || 0), 0); if (total > 0) { table.style.width = `${total}px`; table.style.minWidth = `${total}px`; } } function applyStoredColumnWidths() { let restored = false; [...headerRow.cells].forEach((header, columnIndex) => { if (header.dataset.rowNumberColumn === '1') return; const stored = readStoredColumnWidth(header, columnIndex); if (stored !== null) { applyColumnWidth(columnIndex, stored); restored = true; } }); if (restored && table.id !== 'asset-table') { table.style.tableLayout = 'fixed'; requestAnimationFrame(synchronizeTableWidth); } } function installColumnResizers() { [...headerRow.cells].forEach((header, columnIndex) => { if (header.dataset.rowNumberColumn === '1') return; if (header.dataset.noResize === '1' || header.dataset.rowNumberColumn === '1') return; if (header.querySelector(':scope > .table-column-resizer')) return; header.classList.add('table-resizable-column-header'); const handle = document.createElement('span'); handle.className = 'table-column-resizer'; handle.setAttribute('role', 'separator'); handle.setAttribute('aria-orientation', 'vertical'); handle.tabIndex = 0; handle.title = document.body.dataset.columnResizeLabel || 'Drag to change the column width'; header.appendChild(handle); handle.addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); }); handle.addEventListener('pointerdown', event => { event.preventDefault(); event.stopPropagation(); const startX = event.clientX; freezeTableGeometry(); const headers = [...headerRow.cells]; const rightmostIndex = headers.length - 1; const startWidth = header.getBoundingClientRect().width || minimumColumnWidth; const rightmostHeader = headers[rightmostIndex]; const rightmostStartWidth = rightmostHeader?.getBoundingClientRect().width || minimumColumnWidth; const keepTableWidth = columnIndex !== rightmostIndex; document.documentElement.classList.add('table-column-resizing'); const resizePair = delta => { if (!keepTableWidth) { applyColumnWidth(columnIndex, startWidth + delta); synchronizeTableWidth(); return; } // All columns between the dragged column and the far-right column // stay unchanged. The far-right column alone absorbs the delta. const minDelta = minimumColumnWidth - startWidth; const maxDelta = rightmostStartWidth - minimumColumnWidth; const effectiveDelta = Math.max(minDelta, Math.min(maxDelta, delta)); applyColumnWidth(columnIndex, startWidth + effectiveDelta); applyColumnWidth(rightmostIndex, rightmostStartWidth - effectiveDelta); }; const move = moveEvent => resizePair(moveEvent.clientX - startX); const finish = () => { saveColumnWidth(header, columnIndex, header.getBoundingClientRect().width || startWidth); if (keepTableWidth && rightmostHeader) { saveColumnWidth(rightmostHeader, rightmostIndex, rightmostHeader.getBoundingClientRect().width || rightmostStartWidth); } else { synchronizeTableWidth(); } document.documentElement.classList.remove('table-column-resizing'); window.removeEventListener('pointermove', move, true); window.removeEventListener('pointerup', finish, true); window.removeEventListener('pointercancel', finish, true); }; window.addEventListener('pointermove', move, true); window.addEventListener('pointerup', finish, true); window.addEventListener('pointercancel', finish, true); }); handle.addEventListener('keydown', event => { if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; event.preventDefault(); event.stopPropagation(); freezeTableGeometry(); const headers = [...headerRow.cells]; const rightmostIndex = headers.length - 1; const step = event.key === 'ArrowRight' ? 12 : -12; const current = header.getBoundingClientRect().width || minimumColumnWidth; if (columnIndex === rightmostIndex) { saveColumnWidth(header, columnIndex, current + step); synchronizeTableWidth(); return; } const rightmostHeader = headers[rightmostIndex]; const rightmostWidth = rightmostHeader?.getBoundingClientRect().width || minimumColumnWidth; const minDelta = minimumColumnWidth - current; const maxDelta = rightmostWidth - minimumColumnWidth; const effectiveDelta = Math.max(minDelta, Math.min(maxDelta, step)); saveColumnWidth(header, columnIndex, current + effectiveDelta); if (rightmostHeader) saveColumnWidth(rightmostHeader, rightmostIndex, rightmostWidth - effectiveDelta); }); }); } function ensureRowNumberCells() { [...tbody.rows].forEach(row => { if (row.querySelector('td[data-row-number-column="1"]')) return; const cell = document.createElement('td'); cell.className = 'table-row-number-cell'; cell.dataset.rowNumberColumn = '1'; cell.setAttribute('aria-hidden', 'true'); row.insertBefore(cell, row.firstChild); }); } ensureRowNumberCells(); const isAssetTable = table.id === 'asset-table'; const usesServerFilters = table.dataset.serverFilters === '1'; let serverFilterTimer = null; let serverFilterController = null; let serverFilterRequestId = 0; // 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) { let syntheticIndex = 0; [...headerRow.cells].forEach(header => { if (header.dataset.rowNumberColumn === '1') return; if (!header.dataset.field) { header.dataset.field = `__col_${syntheticIndex}`; header.dataset.syntheticField = '1'; } syntheticIndex += 1; }); [...tbody.rows].forEach(row => { [...row.cells].forEach((cell, index) => { if (cell.dataset.rowNumberColumn === '1') return; 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() { ensureRowNumberCells(); if (isAssetTable) return; [...tbody.rows].forEach(row => { [...row.cells].forEach((cell, index) => { if (cell.dataset.rowNumberColumn === '1') return; 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 = async () => { 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); }); const requestId = ++serverFilterRequestId; serverFilterController?.abort(); serverFilterController = new AbortController(); table.classList.add('server-filter-loading'); table.setAttribute('aria-busy', 'true'); try { const response = await fetch(target.toString(), { cache: 'no-store', headers: {'X-Requested-With': 'AssetManagerTableFilter'}, signal: serverFilterController.signal, }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const html = await response.text(); if (requestId !== serverFilterRequestId) return; const documentCopy = new DOMParser().parseFromString(html, 'text/html'); const replacement = documentCopy.querySelector( `table[data-storage-key="${CSS.escape(table.dataset.storageKey || '')}"]` ); const replacementBody = replacement?.tBodies?.[0]; if (!replacement || !replacementBody) throw new Error('Filtered table not found'); tbody.replaceChildren(...[...replacementBody.rows].map(row => row.cloneNode(true))); table.dataset.serverFilterTotal = replacement.dataset.serverFilterTotal || String(tbody.rows.length); window.history.replaceState({}, '', target.toString()); window.AssetManagerTime?.scan(tbody); applyFilters(); window.dispatchEvent(new CustomEvent('asset-table-content-reloaded', { detail: {table} })); } catch (error) { if (error?.name !== 'AbortError') { // Fall back to a normal navigation only when the partial reload fails. window.location.assign(target.toString()); } } finally { if (requestId === serverFilterRequestId) { table.classList.remove('server-filter-loading'); table.removeAttribute('aria-busy'); } } }; if (immediate) navigate(); else serverFilterTimer = window.setTimeout(navigate, 700); } function updateRowNumbers() { let visibleIndex = 0; [...tbody.rows].forEach(row => { const cell = row.querySelector('td[data-row-number-column="1"]'); if (!cell) return; if (row.hidden) { cell.textContent = ''; return; } visibleIndex += 1; cell.textContent = String(visibleIndex); }); } function applyFilters() { rebuildMissingCellKeys(); const active = filterDefinitions .map(definition => ({ definition, term: 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); updateRowNumbers(); 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 !searchTextMatches(value, term); }); row.hidden = hidden; if (!hidden) visibleRows += 1; }); updateFilterPresentation(active, visibleRows, tbody.rows.length); updateRowNumbers(); window.dispatchEvent(new CustomEvent('asset-table-filter-changed', { detail: { table, visible: visibleRows, total: tbody.rows.length, activeFilters: active.length } })); } function applySort(field, direction, options = {}) { const header = [...headerRow.cells].find(item => item.dataset.field === field); if (!header || header.dataset.noSort) return false; const columnIndex = header.cellIndex; [...headerRow.cells].forEach(item => { delete item.dataset.sortDirection; item.classList.remove('sort-asc', 'sort-desc'); }); const normalizedDirection = direction === 'desc' ? 'desc' : 'asc'; header.dataset.sortDirection = normalizedDirection; header.classList.add(normalizedDirection === '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 normalizedDirection === 'asc' ? result : -result; }); const fragment = document.createDocumentFragment(); rows.forEach(row => fragment.appendChild(row)); tbody.appendChild(fragment); applyFilters(); if (!options.silent) { window.dispatchEvent(new CustomEvent('asset-table-sort-changed', { detail: { table, field, direction: normalizedDirection } })); } return true; } [...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'; applySort(field, direction); }); } const filterCell = document.createElement('th'); if (field) filterCell.dataset.field = field; filterCell.dataset.columnIndex = String(columnIndex); if (header.dataset.rowNumberColumn === '1') { filterCell.dataset.rowNumberColumn = '1'; filterCell.className = 'table-row-number-filter'; } 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); if (isAssetTable) { installColumnResizers(); applyStoredColumnWidths(); } // 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.assetApplySort = (field, direction, options = {}) => applySort(field, direction, options); table.assetUpdateRowNumbers = updateRowNumbers; table.assetRebuildFilterCache = rebuildMissingCellKeys; table.assetApplyStoredColumnWidths = applyStoredColumnWidths; 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 } })); } function initUniversalTableResize(table) { if (!table || table.id === 'asset-table' || table.dataset.columnResizeReady === '1') return; const thead = table.tHead; const tbody = table.tBodies?.[0]; if (!thead || !tbody || !thead.rows.length) return; const headerRow = thead.rows[0]; const storagePart = table.dataset.storageKey || table.id || `${window.location.pathname}:table-${[...document.querySelectorAll('main table')].indexOf(table)}`; const storagePrefix = `assetmanager:table:${storagePart}:column-width:`; const minWidth = 56; const maxWidth = 1200; const normalize = width => Math.min(maxWidth, Math.max(minWidth, Math.round(width))); const columnKey = (header, index) => header.dataset.field || `index-${index}`; const applyWidth = (index, width) => { const normalized = normalize(width); [...thead.rows, ...tbody.rows].forEach(row => { const cell = row.cells?.[index]; if (!cell) return; cell.style.width = `${normalized}px`; cell.style.minWidth = `${normalized}px`; cell.style.maxWidth = `${normalized}px`; }); return normalized; }; const freezeGeometry = () => { const widths = [...headerRow.cells].map(cell => normalize(cell.getBoundingClientRect().width || minWidth)); const total = widths.reduce((sum, width) => sum + width, 0); widths.forEach((width, index) => applyWidth(index, width)); table.style.tableLayout = 'fixed'; table.style.width = `${total}px`; table.style.minWidth = `${total}px`; }; const syncTableWidth = () => { const total = [...headerRow.cells].reduce((sum, cell) => sum + Math.round(cell.getBoundingClientRect().width || 0), 0); if (total > 0) { table.style.width = `${total}px`; table.style.minWidth = `${total}px`; } }; [...headerRow.cells].forEach((header, index) => { if (header.dataset.noResize === '1' || header.dataset.rowNumberColumn === '1') return; const key = `${storagePrefix}${columnKey(header, index)}`; try { const stored = Number.parseInt(localStorage.getItem(key) || '', 10); if (Number.isFinite(stored)) applyWidth(index, stored); } catch (_) {} if (header.querySelector(':scope > .table-column-resizer')) return; header.classList.add('table-resizable-column-header'); const handle = document.createElement('span'); handle.className = 'table-column-resizer'; handle.setAttribute('role', 'separator'); handle.setAttribute('aria-orientation', 'vertical'); handle.tabIndex = 0; handle.title = document.body.dataset.columnResizeLabel || 'Drag to change the column width'; header.appendChild(handle); const save = width => { const normalized = applyWidth(index, width); try { localStorage.setItem(key, String(normalized)); } catch (_) {} }; handle.addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); }); handle.addEventListener('pointerdown', event => { event.preventDefault(); event.stopPropagation(); const startX = event.clientX; freezeGeometry(); const headers = [...headerRow.cells]; const rightmostIndex = headers.length - 1; const startWidth = header.getBoundingClientRect().width || minWidth; const rightmostHeader = headers[rightmostIndex]; const rightmostStartWidth = rightmostHeader?.getBoundingClientRect().width || minWidth; const keepTableWidth = index !== rightmostIndex; document.documentElement.classList.add('table-column-resizing'); const resizePair = delta => { if (!keepTableWidth) { applyWidth(index, startWidth + delta); syncTableWidth(); return; } const minDelta = minWidth - startWidth; const maxDelta = rightmostStartWidth - minWidth; const effectiveDelta = Math.max(minDelta, Math.min(maxDelta, delta)); applyWidth(index, startWidth + effectiveDelta); applyWidth(rightmostIndex, rightmostStartWidth - effectiveDelta); }; const move = e => resizePair(e.clientX - startX); const finish = () => { save(header.getBoundingClientRect().width || startWidth); if (keepTableWidth && rightmostHeader) { const rightKey = `${storagePrefix}${columnKey(rightmostHeader, rightmostIndex)}`; const rightWidth = applyWidth(rightmostIndex, rightmostHeader.getBoundingClientRect().width || rightmostStartWidth); try { localStorage.setItem(rightKey, String(rightWidth)); } catch (_) {} } else { syncTableWidth(); } document.documentElement.classList.remove('table-column-resizing'); window.removeEventListener('pointermove', move, true); window.removeEventListener('pointerup', finish, true); window.removeEventListener('pointercancel', finish, true); }; window.addEventListener('pointermove', move, true); window.addEventListener('pointerup', finish, true); window.addEventListener('pointercancel', finish, true); }); handle.addEventListener('keydown', event => { if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; event.preventDefault(); event.stopPropagation(); freezeGeometry(); const headers = [...headerRow.cells]; const rightmostIndex = headers.length - 1; const step = event.key === 'ArrowRight' ? 12 : -12; const current = header.getBoundingClientRect().width || minWidth; if (index === rightmostIndex) { save(current + step); syncTableWidth(); return; } const rightmostHeader = headers[rightmostIndex]; const rightmostWidth = rightmostHeader?.getBoundingClientRect().width || minWidth; const minDelta = minWidth - current; const maxDelta = rightmostWidth - minWidth; const effectiveDelta = Math.max(minDelta, Math.min(maxDelta, step)); save(current + effectiveDelta); if (rightmostHeader) { const rightKey = `${storagePrefix}${columnKey(rightmostHeader, rightmostIndex)}`; const rightWidth = applyWidth(rightmostIndex, rightmostWidth - effectiveDelta); try { localStorage.setItem(rightKey, String(rightWidth)); } catch (_) {} } }); }); table.dataset.columnResizeReady = '1'; } document.querySelectorAll('table.data-table').forEach(initTable); document.querySelectorAll('main table:not(#asset-table):not([data-column-resize="off"])').forEach(initUniversalTableResize); window.addEventListener('asset-table-content-reloaded', event => { const table = event.detail?.table; if (table?.assetRebuildFilterCache) { table.assetRebuildFilterCache(); const sortedHeader = table.tHead?.rows?.[0]?.querySelector('[data-sort-direction]'); if (sortedHeader?.dataset.field && table.assetApplySort) { table.assetApplySort(sortedHeader.dataset.field, sortedHeader.dataset.sortDirection, {silent: true}); } else { table.assetApplyFilters?.(); } table.assetUpdateRowNumbers?.(); table.assetApplyStoredColumnWidths?.(); } }); })();