836 lines
28 KiB
JavaScript
836 lines
28 KiB
JavaScript
// public/assets/table.js
|
|
// Shared table controller for pagination/infinite mode
|
|
|
|
class TableController {
|
|
constructor(opts) {
|
|
this.table = opts.table;
|
|
this.tbody = this.table.querySelector('tbody');
|
|
this.wrap = this.table.closest('.table-wrap');
|
|
this.footer = this.wrap?.querySelector('[data-table-footer]');
|
|
this.metaEl = this.wrap?.querySelector('[data-table-meta]');
|
|
this.pageEl = this.wrap?.querySelector('[data-table-page]');
|
|
this.prevBtn = this.wrap?.querySelector('[data-table-prev]');
|
|
this.nextBtn = this.wrap?.querySelector('[data-table-next]');
|
|
this.renderRow = opts.renderRow;
|
|
this.fetchPage = opts.fetchPage;
|
|
this.mode = opts.mode || 'pagination';
|
|
this.pageSize = Number(opts.pageSize || 50);
|
|
this.page = 1;
|
|
this.total = 0;
|
|
this.sort = opts.sort || '';
|
|
this.dir = opts.dir || 'asc';
|
|
this.onSortChange = opts.onSortChange || null;
|
|
this.params = opts.params || {};
|
|
this.filters = Array.isArray(opts.filters) ? opts.filters : [];
|
|
this.loading = false;
|
|
|
|
this.attachSortHandlers();
|
|
this.attachPagerHandlers();
|
|
this.initFilters();
|
|
this.initViewEditor();
|
|
this.defaultColumns = this.buildColumnsFromHeaders([]);
|
|
const tableId = this.getTableId();
|
|
if (tableId && window.UserPrefs?.getTable) {
|
|
const prefs = window.UserPrefs.getTable(tableId);
|
|
if (prefs && prefs.columns) {
|
|
this.applyColumnPrefs(prefs);
|
|
}
|
|
}
|
|
}
|
|
|
|
setMode(mode) {
|
|
this.mode = mode === 'infinite' ? 'infinite' : 'pagination';
|
|
}
|
|
|
|
setPageSize(size) {
|
|
this.pageSize = Math.max(1, Number(size || 50));
|
|
}
|
|
|
|
setParams(params) {
|
|
this.params = params || {};
|
|
}
|
|
|
|
setFilters(filters) {
|
|
this.filters = Array.isArray(filters) ? filters : [];
|
|
this.syncFilterInputs();
|
|
}
|
|
|
|
setSort(sort, dir) {
|
|
this.sort = sort || '';
|
|
this.dir = dir === 'desc' ? 'desc' : 'asc';
|
|
}
|
|
|
|
attachSortHandlers() {
|
|
const headers = this.table.querySelectorAll('thead th[data-sort]');
|
|
headers.forEach(th => {
|
|
th.classList.add('sortable');
|
|
th.addEventListener('click', () => {
|
|
const key = th.dataset.sort || '';
|
|
if (!key) return;
|
|
if (this.sort === key) {
|
|
this.dir = (this.dir === 'asc') ? 'desc' : 'asc';
|
|
} else {
|
|
this.sort = key;
|
|
this.dir = 'asc';
|
|
}
|
|
if (typeof this.onSortChange === 'function') {
|
|
this.onSortChange(this.sort, this.dir);
|
|
}
|
|
this.load(1, false);
|
|
});
|
|
});
|
|
}
|
|
|
|
attachPagerHandlers() {
|
|
if (this.prevBtn) {
|
|
this.prevBtn.addEventListener('click', () => {
|
|
if (this.page > 1) this.load(this.page - 1, false);
|
|
});
|
|
}
|
|
if (this.nextBtn) {
|
|
this.nextBtn.addEventListener('click', () => {
|
|
const totalPages = this.totalPages();
|
|
if (this.page < totalPages) this.load(this.page + 1, false);
|
|
});
|
|
}
|
|
|
|
const handleScroll = () => {
|
|
if (this.mode !== 'infinite') return;
|
|
if (this.loading) return;
|
|
if (this.page >= this.totalPages()) return;
|
|
if (this.isWrapScrollable()) {
|
|
const nearBottom = this.wrap.scrollTop + this.wrap.clientHeight >= this.wrap.scrollHeight - 80;
|
|
if (nearBottom) this.load(this.page + 1, true);
|
|
} else {
|
|
const doc = document.documentElement;
|
|
const nearBottom = window.scrollY + window.innerHeight >= doc.scrollHeight - 120;
|
|
if (nearBottom) this.load(this.page + 1, true);
|
|
}
|
|
};
|
|
|
|
if (this.wrap) {
|
|
this.wrap.addEventListener('scroll', handleScroll);
|
|
}
|
|
window.addEventListener('scroll', handleScroll);
|
|
}
|
|
|
|
initFilters() {
|
|
const headRow = this.table?.querySelector('thead tr');
|
|
if (!headRow) return;
|
|
const filtersEnabled = this.table?.dataset?.filtersEnabled;
|
|
if (filtersEnabled === '0') {
|
|
const tools = this.wrap?.querySelector('[data-table-tools]');
|
|
if (tools) tools.classList.add('is-hidden');
|
|
return;
|
|
}
|
|
const ths = Array.from(headRow.querySelectorAll('th'));
|
|
const hasFilters = ths.some(th => th.dataset.filter);
|
|
const tools = this.wrap?.querySelector('[data-table-tools]');
|
|
const toggleBtn = this.wrap?.querySelector('[data-table-filters-toggle]');
|
|
const summaryEl = this.wrap?.querySelector('[data-table-filters-summary]');
|
|
|
|
if (!hasFilters) {
|
|
if (tools) tools.classList.add('is-hidden');
|
|
return;
|
|
}
|
|
if (tools) tools.classList.remove('is-hidden');
|
|
|
|
const ensureIndicator = (th) => {
|
|
if (!th.classList.contains('has-filter')) {
|
|
th.classList.add('has-filter');
|
|
}
|
|
if (!th.querySelector('.filter-indicator')) {
|
|
const dot = document.createElement('span');
|
|
dot.className = 'filter-indicator';
|
|
dot.setAttribute('aria-hidden', 'true');
|
|
th.appendChild(dot);
|
|
}
|
|
};
|
|
|
|
ths.forEach((th) => {
|
|
if (th.dataset.filter) {
|
|
ensureIndicator(th);
|
|
}
|
|
});
|
|
|
|
let row = this.table.querySelector('thead tr[data-filter-row]');
|
|
if (!row) {
|
|
row = document.createElement('tr');
|
|
row.className = 'table-filter-row';
|
|
row.dataset.filterRow = '1';
|
|
headRow.insertAdjacentElement('afterend', row);
|
|
ths.forEach((th) => {
|
|
const td = document.createElement('td');
|
|
const key = th.dataset.key || '';
|
|
if (key) td.dataset.key = key;
|
|
row.appendChild(td);
|
|
});
|
|
}
|
|
this.filterRow = row;
|
|
this.filterThs = ths;
|
|
this.filterSummaryEl = summaryEl;
|
|
|
|
const parseFilter = (value) => {
|
|
if (!value) return null;
|
|
try {
|
|
const obj = JSON.parse(value);
|
|
if (obj && typeof obj === 'object') return obj;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const defaultOp = (meta, type) => {
|
|
if (Array.isArray(meta?.ops) && meta.ops.length > 0) return meta.ops[0];
|
|
if (type === 'text') return 'like';
|
|
return 'eq';
|
|
};
|
|
|
|
const buildInput = (meta, key) => {
|
|
const type = meta?.type || 'text';
|
|
const control = meta?.control || '';
|
|
const isSelect = type === 'select' || control === 'select' || Array.isArray(meta?.options);
|
|
const ops = Array.isArray(meta?.ops) ? meta.ops : [];
|
|
const op = defaultOp(meta, type);
|
|
if (isSelect) {
|
|
const dict = window.I18N || {};
|
|
const t = (i18nKey, fallback) => {
|
|
if (i18nKey && Object.prototype.hasOwnProperty.call(dict, i18nKey)) {
|
|
const v = dict[i18nKey];
|
|
if (typeof v === 'string' && v.length) return v;
|
|
}
|
|
return fallback;
|
|
};
|
|
const select = document.createElement('select');
|
|
select.dataset.filterKey = key;
|
|
select.dataset.filterOp = op;
|
|
const optAny = document.createElement('option');
|
|
optAny.value = '';
|
|
optAny.textContent = t('filters.all', 'All');
|
|
select.appendChild(optAny);
|
|
const options = Array.isArray(meta?.options) ? meta.options : [];
|
|
options.forEach((value) => {
|
|
const opt = document.createElement('option');
|
|
if (value && typeof value === 'object') {
|
|
opt.value = String(value.value ?? '');
|
|
opt.textContent = t(value.i18n || '', String(value.label ?? value.value ?? ''));
|
|
} else {
|
|
opt.value = String(value);
|
|
opt.textContent = String(value);
|
|
}
|
|
select.appendChild(opt);
|
|
});
|
|
return select;
|
|
}
|
|
if (type === 'boolean') {
|
|
const select = document.createElement('select');
|
|
select.dataset.filterKey = key;
|
|
select.dataset.filterOp = op;
|
|
const optAny = document.createElement('option');
|
|
optAny.value = '';
|
|
optAny.textContent = 'All';
|
|
select.appendChild(optAny);
|
|
const optTrue = document.createElement('option');
|
|
optTrue.value = 'true';
|
|
optTrue.textContent = 'Yes';
|
|
select.appendChild(optTrue);
|
|
const optFalse = document.createElement('option');
|
|
optFalse.value = 'false';
|
|
optFalse.textContent = 'No';
|
|
select.appendChild(optFalse);
|
|
return select;
|
|
}
|
|
if ((type === 'number' || type === 'date') && ops.includes('between')) {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'table-filter-range';
|
|
const inputFrom = document.createElement('input');
|
|
const inputTo = document.createElement('input');
|
|
inputFrom.type = type === 'date' ? 'date' : 'number';
|
|
inputTo.type = type === 'date' ? 'date' : 'number';
|
|
inputFrom.dataset.filterKey = key;
|
|
inputFrom.dataset.filterOp = 'between';
|
|
inputFrom.dataset.filterRange = 'from';
|
|
inputTo.dataset.filterKey = key;
|
|
inputTo.dataset.filterOp = 'between';
|
|
inputTo.dataset.filterRange = 'to';
|
|
wrap.appendChild(inputFrom);
|
|
wrap.appendChild(inputTo);
|
|
return wrap;
|
|
}
|
|
const input = document.createElement('input');
|
|
input.type = type === 'number' ? 'number' : (type === 'date' ? 'date' : 'text');
|
|
input.dataset.filterKey = key;
|
|
input.dataset.filterOp = op;
|
|
return input;
|
|
};
|
|
|
|
const cells = Array.from(row.children);
|
|
ths.forEach((th, idx) => {
|
|
const meta = parseFilter(th.dataset.filter || '');
|
|
const key = th.dataset.key || '';
|
|
const cell = cells[idx];
|
|
if (!cell) return;
|
|
cell.innerHTML = '';
|
|
if (!meta || key === '') return;
|
|
const control = buildInput(meta, key);
|
|
cell.appendChild(control);
|
|
});
|
|
|
|
let debounceId = null;
|
|
const readFilters = () => {
|
|
const filters = [];
|
|
const inputs = row.querySelectorAll('[data-filter-key]');
|
|
const rangeMap = new Map();
|
|
inputs.forEach((el) => {
|
|
const key = el.dataset.filterKey || '';
|
|
const op = el.dataset.filterOp || 'eq';
|
|
if (!key) return;
|
|
if (el.dataset.filterRange) {
|
|
const entry = rangeMap.get(key) || { from: '', to: '', op };
|
|
if (el.dataset.filterRange === 'from') entry.from = el.value || '';
|
|
if (el.dataset.filterRange === 'to') entry.to = el.value || '';
|
|
rangeMap.set(key, entry);
|
|
return;
|
|
}
|
|
const value = el.value;
|
|
if (value === '' || value === null || value === undefined) return;
|
|
filters.push({ key, op, value });
|
|
});
|
|
rangeMap.forEach((entry, key) => {
|
|
if (entry.from && entry.to) {
|
|
filters.push({ key, op: 'between', value: [entry.from, entry.to] });
|
|
}
|
|
});
|
|
return filters;
|
|
};
|
|
|
|
const updateIndicators = (filters) => {
|
|
const active = new Set(filters.map(f => f.key));
|
|
ths.forEach((th) => {
|
|
if (!th.dataset.filter) return;
|
|
const key = th.dataset.key || '';
|
|
th.classList.toggle('filter-active', key !== '' && active.has(key));
|
|
});
|
|
if (summaryEl) {
|
|
summaryEl.textContent = filters.length ? `Filters: ${filters.length}` : '';
|
|
}
|
|
};
|
|
|
|
const applyFilters = () => {
|
|
const filters = readFilters();
|
|
this.setFilters(filters);
|
|
updateIndicators(filters);
|
|
this.load(1, false);
|
|
};
|
|
|
|
const handleInput = (e) => {
|
|
const target = e.target;
|
|
if (!target || !target.dataset.filterKey) return;
|
|
if (target.tagName === 'INPUT' && target.type === 'text') {
|
|
if (debounceId) clearTimeout(debounceId);
|
|
debounceId = setTimeout(applyFilters, 350);
|
|
return;
|
|
}
|
|
applyFilters();
|
|
};
|
|
|
|
row.addEventListener('input', handleInput);
|
|
row.addEventListener('change', handleInput);
|
|
|
|
if (toggleBtn && !toggleBtn.dataset.filtersInit) {
|
|
toggleBtn.dataset.filtersInit = '1';
|
|
toggleBtn.addEventListener('click', () => {
|
|
if (!this.wrap) return;
|
|
this.wrap.classList.toggle('filters-open');
|
|
});
|
|
}
|
|
updateIndicators(this.filters);
|
|
}
|
|
|
|
initViewEditor() {
|
|
const dialog = document.getElementById('dlg-table-view');
|
|
const tools = this.wrap?.querySelector('[data-table-tools]');
|
|
const viewBtn = this.wrap?.querySelector('[data-table-view-toggle]');
|
|
if (!dialog || !viewBtn) return;
|
|
const hasConfig = this.table?.dataset?.viewConfig === '1';
|
|
if (!hasConfig) {
|
|
if (viewBtn) viewBtn.classList.add('is-hidden');
|
|
if (tools && !this.table?.querySelector('thead th[data-filter]')) {
|
|
tools.classList.add('is-hidden');
|
|
}
|
|
return;
|
|
}
|
|
viewBtn.classList.remove('is-hidden');
|
|
if (viewBtn.dataset.viewInit === '1') return;
|
|
viewBtn.dataset.viewInit = '1';
|
|
|
|
viewBtn.addEventListener('click', () => {
|
|
this.openViewEditor(dialog);
|
|
});
|
|
}
|
|
|
|
openViewEditor(dialog) {
|
|
if (!dialog) return;
|
|
const body = dialog.querySelector('[data-table-view-body]');
|
|
if (!body) return;
|
|
const tableId = this.getTableId();
|
|
const prefs = window.UserPrefs?.getTable?.(tableId) || {};
|
|
const columns = this.buildColumnsFromHeaders(prefs.columns || []);
|
|
|
|
body.innerHTML = '';
|
|
columns.forEach((col, idx) => {
|
|
const tr = document.createElement('tr');
|
|
tr.className = 'table-view-row';
|
|
tr.dataset.key = col.key;
|
|
|
|
const tdOn = document.createElement('td');
|
|
const chk = document.createElement('input');
|
|
chk.type = 'checkbox';
|
|
chk.checked = col.visible !== false;
|
|
tdOn.appendChild(chk);
|
|
|
|
const tdLabel = document.createElement('td');
|
|
tdLabel.textContent = col.label;
|
|
|
|
const tdWidth = document.createElement('td');
|
|
const sel = document.createElement('select');
|
|
const optAuto = document.createElement('option');
|
|
optAuto.value = 'auto';
|
|
optAuto.textContent = 'Auto';
|
|
const optManual = document.createElement('option');
|
|
optManual.value = 'manual';
|
|
optManual.textContent = 'Manual';
|
|
sel.appendChild(optAuto);
|
|
sel.appendChild(optManual);
|
|
const widthInput = document.createElement('input');
|
|
widthInput.type = 'number';
|
|
widthInput.min = '40';
|
|
widthInput.max = '800';
|
|
widthInput.value = col.width ? String(col.width) : '';
|
|
const widthMode = col.width ? 'manual' : 'auto';
|
|
sel.value = widthMode;
|
|
widthInput.disabled = widthMode !== 'manual';
|
|
sel.addEventListener('change', () => {
|
|
widthInput.disabled = sel.value !== 'manual';
|
|
});
|
|
tdWidth.appendChild(sel);
|
|
tdWidth.appendChild(widthInput);
|
|
|
|
const tdHeadAlign = document.createElement('td');
|
|
const headAlign = document.createElement('select');
|
|
['left', 'center', 'right'].forEach((val) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = val;
|
|
opt.textContent = val;
|
|
headAlign.appendChild(opt);
|
|
});
|
|
headAlign.value = col.headAlign || 'left';
|
|
tdHeadAlign.appendChild(headAlign);
|
|
|
|
const tdCellAlign = document.createElement('td');
|
|
const cellAlign = document.createElement('select');
|
|
['left', 'center', 'right'].forEach((val) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = val;
|
|
opt.textContent = val;
|
|
cellAlign.appendChild(opt);
|
|
});
|
|
cellAlign.value = col.cellAlign || 'left';
|
|
tdCellAlign.appendChild(cellAlign);
|
|
|
|
const tdOrder = document.createElement('td');
|
|
const upBtn = document.createElement('button');
|
|
upBtn.type = 'button';
|
|
upBtn.className = 'btn';
|
|
upBtn.textContent = '↑';
|
|
const downBtn = document.createElement('button');
|
|
downBtn.type = 'button';
|
|
downBtn.className = 'btn';
|
|
downBtn.textContent = '↓';
|
|
upBtn.addEventListener('click', () => {
|
|
const prev = tr.previousElementSibling;
|
|
if (prev) prev.before(tr);
|
|
});
|
|
downBtn.addEventListener('click', () => {
|
|
const next = tr.nextElementSibling;
|
|
if (next) next.after(tr);
|
|
});
|
|
tdOrder.appendChild(upBtn);
|
|
tdOrder.appendChild(downBtn);
|
|
|
|
tr.appendChild(tdOn);
|
|
tr.appendChild(tdLabel);
|
|
tr.appendChild(tdWidth);
|
|
tr.appendChild(tdHeadAlign);
|
|
tr.appendChild(tdCellAlign);
|
|
tr.appendChild(tdOrder);
|
|
body.appendChild(tr);
|
|
});
|
|
|
|
const applyBtn = dialog.querySelector('[data-table-view-apply]');
|
|
const resetBtn = dialog.querySelector('[data-table-view-reset]');
|
|
if (applyBtn && applyBtn.dataset.bound !== '1') {
|
|
applyBtn.dataset.bound = '1';
|
|
applyBtn.addEventListener('click', () => {
|
|
const rows = Array.from(body.querySelectorAll('tr.table-view-row'));
|
|
const cols = rows.map((row) => {
|
|
const key = row.dataset.key || '';
|
|
const inputs = row.querySelectorAll('input, select');
|
|
const chk = inputs[0];
|
|
const mode = inputs[1];
|
|
const widthInput = inputs[2];
|
|
const headAlign = inputs[3];
|
|
const cellAlign = inputs[4];
|
|
const width = mode && mode.value === 'manual' ? Number(widthInput.value || 0) : 0;
|
|
return {
|
|
key,
|
|
visible: chk?.checked !== false,
|
|
width: width > 0 ? width : 0,
|
|
headAlign: headAlign?.value || 'left',
|
|
cellAlign: cellAlign?.value || 'left',
|
|
};
|
|
}).filter(c => c.key !== '');
|
|
window.UserPrefs?.setTable?.(tableId, { columns: cols });
|
|
this.applyColumnPrefs({ columns: cols });
|
|
dialog.close();
|
|
});
|
|
}
|
|
if (resetBtn && resetBtn.dataset.bound !== '1') {
|
|
resetBtn.dataset.bound = '1';
|
|
resetBtn.addEventListener('click', () => {
|
|
window.UserPrefs?.setTable?.(tableId, { columns: [] });
|
|
this.applyColumnPrefs({ columns: [] });
|
|
dialog.close();
|
|
});
|
|
}
|
|
dialog.showModal();
|
|
}
|
|
|
|
buildColumnsFromHeaders(saved) {
|
|
const headRow = this.table?.querySelector('thead tr');
|
|
if (!headRow) return [];
|
|
const ths = Array.from(headRow.querySelectorAll('th'));
|
|
const base = ths.map((th) => ({
|
|
key: th.dataset.key || '',
|
|
label: th.textContent?.trim() || '',
|
|
visible: true,
|
|
width: 0,
|
|
headAlign: th.classList.contains('align-right') ? 'right' : (th.classList.contains('align-center') ? 'center' : 'left'),
|
|
cellAlign: th.classList.contains('align-right') ? 'right' : (th.classList.contains('align-center') ? 'center' : 'left'),
|
|
})).filter(c => c.key !== '');
|
|
|
|
if (!Array.isArray(saved) || saved.length === 0) return base;
|
|
const map = new Map(saved.map(c => [c.key, c]));
|
|
const ordered = [];
|
|
saved.forEach((c) => {
|
|
const baseCol = base.find(b => b.key === c.key);
|
|
if (!baseCol) return;
|
|
ordered.push({
|
|
...baseCol,
|
|
visible: c.visible !== false,
|
|
width: Number(c.width || 0),
|
|
headAlign: c.headAlign || baseCol.headAlign || 'left',
|
|
cellAlign: c.cellAlign || baseCol.cellAlign || 'left',
|
|
});
|
|
});
|
|
base.forEach((c) => {
|
|
if (!map.has(c.key)) ordered.push(c);
|
|
});
|
|
return ordered;
|
|
}
|
|
|
|
getTableId() {
|
|
return this.table?.dataset?.tableId || this.table?.id || '';
|
|
}
|
|
|
|
applyColumnPrefs(prefs) {
|
|
let columns = Array.isArray(prefs?.columns) ? prefs.columns : [];
|
|
if (columns.length === 0 && Array.isArray(this.defaultColumns)) {
|
|
columns = this.defaultColumns.map(c => ({ ...c }));
|
|
}
|
|
if (columns.length === 0) return;
|
|
this.columnPrefs = columns;
|
|
const headRow = this.table?.querySelector('thead tr');
|
|
if (!headRow) return;
|
|
const ths = Array.from(headRow.querySelectorAll('th'));
|
|
const keyToIndex = new Map();
|
|
ths.forEach((th, idx) => {
|
|
const key = th.dataset.key || '';
|
|
if (key) keyToIndex.set(key, idx);
|
|
});
|
|
const order = columns.map(c => c.key).filter(k => keyToIndex.has(k));
|
|
const used = new Set(order);
|
|
ths.forEach((th) => {
|
|
const key = th.dataset.key || '';
|
|
if (key && !used.has(key)) order.push(key);
|
|
});
|
|
this.columnOrder = order;
|
|
this.reorderColumns(order, ths.length, keyToIndex);
|
|
this.applyColumnStyles();
|
|
}
|
|
|
|
reorderColumns(order, count, keyToIndex) {
|
|
if (!this.table) return;
|
|
const rows = Array.from(this.table.querySelectorAll('tr'));
|
|
rows.forEach((row) => {
|
|
const cells = Array.from(row.children);
|
|
if (cells.length !== count) return;
|
|
const cellByKey = new Map();
|
|
let hasKeys = false;
|
|
cells.forEach((cell) => {
|
|
const key = cell.dataset?.key || '';
|
|
if (key) {
|
|
cellByKey.set(key, cell);
|
|
hasKeys = true;
|
|
}
|
|
});
|
|
if (hasKeys) {
|
|
const newCells = [];
|
|
const used = new Set();
|
|
order.forEach((key) => {
|
|
const cell = cellByKey.get(key);
|
|
if (cell) {
|
|
newCells.push(cell);
|
|
used.add(cell);
|
|
}
|
|
});
|
|
cells.forEach((cell) => {
|
|
if (!used.has(cell)) newCells.push(cell);
|
|
});
|
|
if (newCells.length !== cells.length) return;
|
|
newCells.forEach((cell) => row.appendChild(cell));
|
|
return;
|
|
}
|
|
const newCells = [];
|
|
order.forEach((key) => {
|
|
const idx = keyToIndex.get(key);
|
|
if (idx >= 0 && cells[idx]) newCells.push(cells[idx]);
|
|
});
|
|
if (newCells.length !== count) return;
|
|
newCells.forEach((cell) => row.appendChild(cell));
|
|
});
|
|
}
|
|
|
|
applyColumnVisibility(columns, order) {
|
|
if (!this.table) return;
|
|
const visibility = new Map(columns.map(c => [c.key, c.visible !== false]));
|
|
const rows = Array.from(this.table.querySelectorAll('tr'));
|
|
rows.forEach((row) => {
|
|
const cells = Array.from(row.children);
|
|
if (cells.length !== order.length) return;
|
|
order.forEach((key, idx) => {
|
|
const visible = visibility.get(key);
|
|
if (visible === undefined) return;
|
|
cells[idx].classList.toggle('is-hidden', !visible);
|
|
});
|
|
});
|
|
}
|
|
|
|
applyFilterVisibility(columns, order) {
|
|
const row = this.filterRow;
|
|
if (!row) return;
|
|
const visibility = new Map(columns.map(c => [c.key, c.visible !== false]));
|
|
const visibleKeys = new Set(columns.filter(c => c.visible !== false).map(c => c.key));
|
|
const cells = Array.from(row.children);
|
|
cells.forEach((cell, idx) => {
|
|
const key = cell.dataset?.key || order[idx] || '';
|
|
if (!key) return;
|
|
const visible = visibility.get(key);
|
|
if (visible === undefined) return;
|
|
cell.classList.toggle('is-hidden', !visible);
|
|
const inputs = cell.querySelectorAll('input, select, textarea, button');
|
|
inputs.forEach((el) => {
|
|
el.disabled = !visible;
|
|
if (!visible && 'value' in el) el.value = '';
|
|
});
|
|
});
|
|
|
|
const before = Array.isArray(this.filters) ? this.filters : [];
|
|
const next = before.filter(f => visibleKeys.has(f.key));
|
|
if (next.length !== before.length) {
|
|
this.filters = next;
|
|
this.syncFilterInputs();
|
|
}
|
|
}
|
|
|
|
applyColumnWidths(columns, order) {
|
|
if (!this.table) return;
|
|
const widths = new Map(columns.map(c => [c.key, Number(c.width || 0)]));
|
|
const rows = Array.from(this.table.querySelectorAll('tr'));
|
|
rows.forEach((row) => {
|
|
const cells = Array.from(row.children);
|
|
if (cells.length !== order.length) return;
|
|
order.forEach((key, idx) => {
|
|
const width = widths.get(key) || 0;
|
|
if (width > 0) {
|
|
cells[idx].style.width = `${width}px`;
|
|
} else {
|
|
cells[idx].style.width = '';
|
|
}
|
|
});
|
|
});
|
|
this.table.style.tableLayout = 'fixed';
|
|
}
|
|
|
|
applyColumnStyles() {
|
|
const columns = Array.isArray(this.columnPrefs) ? this.columnPrefs : [];
|
|
const order = Array.isArray(this.columnOrder) ? this.columnOrder : [];
|
|
if (columns.length === 0 || order.length === 0) return;
|
|
const headRow = this.table?.querySelector('thead tr');
|
|
if (headRow) {
|
|
const ths = Array.from(headRow.querySelectorAll('th'));
|
|
const keyToIndex = new Map();
|
|
ths.forEach((th, idx) => {
|
|
const key = th.dataset.key || '';
|
|
if (key) keyToIndex.set(key, idx);
|
|
});
|
|
this.reorderColumns(order, ths.length, keyToIndex);
|
|
}
|
|
this.applyColumnVisibility(columns, order);
|
|
this.applyFilterVisibility(columns, order);
|
|
this.applyColumnWidths(columns, order);
|
|
this.applyColumnAlign(columns, order);
|
|
}
|
|
|
|
applyColumnAlign(columns, order) {
|
|
if (!this.table) return;
|
|
const headAlign = new Map(columns.map(c => [c.key, c.headAlign || 'left']));
|
|
const cellAlign = new Map(columns.map(c => [c.key, c.cellAlign || 'left']));
|
|
const rows = Array.from(this.table.querySelectorAll('tr'));
|
|
rows.forEach((row) => {
|
|
const isHead = row.parentElement?.tagName === 'THEAD';
|
|
const cells = Array.from(row.children);
|
|
if (cells.length !== order.length) return;
|
|
order.forEach((key, idx) => {
|
|
const align = (isHead ? headAlign.get(key) : cellAlign.get(key)) || 'left';
|
|
const cell = cells[idx];
|
|
if (!cell) return;
|
|
cell.classList.remove('align-left', 'align-center', 'align-right');
|
|
cell.classList.add(`align-${align}`);
|
|
});
|
|
});
|
|
}
|
|
|
|
syncFilterInputs() {
|
|
const row = this.filterRow;
|
|
if (!row) return;
|
|
const inputs = row.querySelectorAll('[data-filter-key]');
|
|
const rangeMap = new Map();
|
|
inputs.forEach((el) => {
|
|
if (el.dataset.filterRange) {
|
|
const key = el.dataset.filterKey || '';
|
|
if (!key) return;
|
|
const entry = rangeMap.get(key) || { fromEl: null, toEl: null };
|
|
if (el.dataset.filterRange === 'from') entry.fromEl = el;
|
|
if (el.dataset.filterRange === 'to') entry.toEl = el;
|
|
rangeMap.set(key, entry);
|
|
return;
|
|
}
|
|
el.value = '';
|
|
});
|
|
rangeMap.forEach((entry) => {
|
|
if (entry.fromEl) entry.fromEl.value = '';
|
|
if (entry.toEl) entry.toEl.value = '';
|
|
});
|
|
const filters = Array.isArray(this.filters) ? this.filters : [];
|
|
filters.forEach((f) => {
|
|
const key = f?.key || '';
|
|
if (!key) return;
|
|
if (f.op === 'between' && Array.isArray(f.value)) {
|
|
const entry = rangeMap.get(key);
|
|
if (entry?.fromEl) entry.fromEl.value = String(f.value[0] ?? '');
|
|
if (entry?.toEl) entry.toEl.value = String(f.value[1] ?? '');
|
|
return;
|
|
}
|
|
inputs.forEach((el) => {
|
|
if (el.dataset.filterKey !== key) return;
|
|
if (el.dataset.filterRange) return;
|
|
el.value = f.value ?? '';
|
|
});
|
|
});
|
|
if (this.filterThs) {
|
|
const active = new Set(filters.map(f => f.key));
|
|
this.filterThs.forEach((th) => {
|
|
if (!th.dataset.filter) return;
|
|
const key = th.dataset.key || '';
|
|
th.classList.toggle('filter-active', key !== '' && active.has(key));
|
|
});
|
|
}
|
|
if (this.filterSummaryEl) {
|
|
this.filterSummaryEl.textContent = filters.length ? `Filters: ${filters.length}` : '';
|
|
}
|
|
}
|
|
|
|
isWrapScrollable() {
|
|
if (!this.wrap) return false;
|
|
return this.wrap.scrollHeight > this.wrap.clientHeight + 4;
|
|
}
|
|
|
|
totalPages() {
|
|
return Math.max(1, Math.ceil((this.total || 0) / this.pageSize));
|
|
}
|
|
|
|
async load(page = 1, append = false) {
|
|
if (!this.fetchPage || !this.renderRow || !this.tbody) return;
|
|
if (this.loading) return;
|
|
this.loading = true;
|
|
|
|
const payload = {
|
|
page,
|
|
per_page: this.pageSize,
|
|
sort: this.sort,
|
|
dir: this.dir,
|
|
filters: this.filters,
|
|
params: this.params,
|
|
};
|
|
|
|
try {
|
|
const res = await this.fetchPage(payload);
|
|
const items = res.items || [];
|
|
this.total = Number(res.total || 0);
|
|
this.page = Number(res.page || page);
|
|
if (!append) {
|
|
this.tbody.innerHTML = '';
|
|
}
|
|
items.forEach(item => this.renderRow(this.tbody, item));
|
|
this.applyColumnStyles();
|
|
this.updateFooter();
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
}
|
|
|
|
reload() {
|
|
return this.load(this.page, false);
|
|
}
|
|
|
|
updateFooter() {
|
|
if (!this.footer) return;
|
|
if (this.mode === 'infinite') {
|
|
if (this.prevBtn) this.prevBtn.disabled = true;
|
|
if (this.nextBtn) this.nextBtn.disabled = true;
|
|
} else {
|
|
const totalPages = this.totalPages();
|
|
if (this.prevBtn) this.prevBtn.disabled = this.page <= 1;
|
|
if (this.nextBtn) this.nextBtn.disabled = this.page >= totalPages;
|
|
}
|
|
|
|
if (this.pageEl) {
|
|
const totalPages = this.totalPages();
|
|
this.pageEl.textContent = `${this.page} / ${totalPages}`;
|
|
}
|
|
if (this.metaEl) {
|
|
const start = (this.page - 1) * this.pageSize + 1;
|
|
const end = Math.min(this.total, this.page * this.pageSize);
|
|
if (this.total === 0) {
|
|
this.metaEl.textContent = '';
|
|
} else {
|
|
this.metaEl.textContent = `${start}-${end} / ${this.total}`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
window.TableController = TableController;
|