// 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;