1070 lines
41 KiB
JavaScript
1070 lines
41 KiB
JavaScript
(() => {
|
|
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?.();
|
|
}
|
|
});
|
|
})();
|