Some checks failed
Deploy Application / deploy (push) Has been cancelled
375 lines
12 KiB
JavaScript
375 lines
12 KiB
JavaScript
/**
|
|
* Admin Data Table Module
|
|
*
|
|
* Provides interactive functionality for admin data tables:
|
|
* - AJAX loading and pagination
|
|
* - Sorting by columns
|
|
* - Search/filtering
|
|
* - Auto-refresh
|
|
*/
|
|
export class AdminDataTable {
|
|
constructor(element) {
|
|
this.element = element;
|
|
|
|
this.resource = element.dataset.resource;
|
|
this.apiEndpoint = element.dataset.apiEndpoint;
|
|
this.sortable = element.dataset.sortable === 'true';
|
|
this.searchable = element.dataset.searchable === 'true';
|
|
this.paginated = element.dataset.paginated === 'true';
|
|
this.perPage = parseInt(element.dataset.perPage) || 25;
|
|
|
|
this.currentPage = 1;
|
|
this.currentSort = null;
|
|
this.currentSearch = '';
|
|
}
|
|
|
|
async init() {
|
|
this.tbody = this.element.querySelector('tbody');
|
|
this.thead = this.element.querySelector('thead');
|
|
|
|
if (!this.tbody) {
|
|
console.warn('AdminDataTable: No tbody found');
|
|
return;
|
|
}
|
|
|
|
// Always setup event listeners for sorting, even if data is already loaded
|
|
this.setupEventListeners();
|
|
|
|
// Only load data via AJAX if API endpoint is configured and table is empty
|
|
if (this.apiEndpoint && this.tbody.children.length === 0) {
|
|
await this.loadData();
|
|
}
|
|
}
|
|
|
|
setupEventListeners() {
|
|
// Sortable columns - only for columns with data-column attribute
|
|
// Check both table-level sortable flag and individual column sortable attribute
|
|
if (this.thead) {
|
|
this.thead.querySelectorAll('th[data-column]').forEach((th) => {
|
|
const columnKey = th.dataset.column;
|
|
|
|
if (columnKey) {
|
|
// Only make sortable if table-level sortable is enabled OR column has data-column
|
|
if (this.sortable || th.hasAttribute('data-column')) {
|
|
th.style.cursor = 'pointer';
|
|
th.classList.add('sortable');
|
|
|
|
th.addEventListener('click', () => {
|
|
// If API endpoint is available, use AJAX sorting
|
|
if (this.apiEndpoint) {
|
|
this.sortByColumn(columnKey, th);
|
|
} else {
|
|
// Otherwise, do client-side sorting
|
|
this.sortByColumnClientSide(columnKey, th);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Search input
|
|
if (this.searchable) {
|
|
const searchInput = this.getSearchInput();
|
|
if (searchInput) {
|
|
searchInput.addEventListener('input', this.debounce(() => {
|
|
this.currentSearch = searchInput.value;
|
|
this.currentPage = 1;
|
|
this.loadData();
|
|
}, 300));
|
|
}
|
|
}
|
|
|
|
// Pagination links (if exist)
|
|
this.setupPaginationListeners();
|
|
}
|
|
|
|
setupPaginationListeners() {
|
|
const paginationContainer = this.getPaginationContainer();
|
|
if (!paginationContainer) return;
|
|
|
|
paginationContainer.addEventListener('click', (e) => {
|
|
const pageLink = e.target.closest('[data-page]');
|
|
if (pageLink) {
|
|
e.preventDefault();
|
|
const page = parseInt(pageLink.dataset.page);
|
|
if (page && page !== this.currentPage) {
|
|
this.currentPage = page;
|
|
this.loadData();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async sortByColumn(columnKey, th) {
|
|
// Toggle sort direction
|
|
const currentDir = th.dataset.sortDir;
|
|
const newDir = currentDir === 'asc' ? 'desc' : 'asc';
|
|
|
|
// Update sort state
|
|
this.currentSort = { column: columnKey, direction: newDir };
|
|
|
|
// Update UI
|
|
this.thead.querySelectorAll('th').forEach(header => {
|
|
header.removeAttribute('data-sort-dir');
|
|
header.classList.remove('sort-asc', 'sort-desc');
|
|
});
|
|
|
|
th.dataset.sortDir = newDir;
|
|
th.classList.add(`sort-${newDir}`);
|
|
|
|
// Reload data if API endpoint is available
|
|
if (this.apiEndpoint) {
|
|
await this.loadData();
|
|
}
|
|
}
|
|
|
|
sortByColumnClientSide(columnKey, th) {
|
|
// Client-side sorting for tables without API endpoint
|
|
const currentDir = th.dataset.sortDir;
|
|
const newDir = currentDir === 'asc' ? 'desc' : 'asc';
|
|
|
|
// Update UI
|
|
this.thead.querySelectorAll('th').forEach(header => {
|
|
header.removeAttribute('data-sort-dir');
|
|
header.classList.remove('sort-asc', 'sort-desc');
|
|
});
|
|
|
|
th.dataset.sortDir = newDir;
|
|
th.classList.add(`sort-${newDir}`);
|
|
|
|
// Get all rows
|
|
const rows = Array.from(this.tbody.querySelectorAll('tr'));
|
|
const columnIndex = Array.from(this.thead.querySelectorAll('th')).indexOf(th);
|
|
|
|
// Sort rows
|
|
rows.sort((a, b) => {
|
|
const aValue = a.cells[columnIndex]?.textContent?.trim() || '';
|
|
const bValue = b.cells[columnIndex]?.textContent?.trim() || '';
|
|
|
|
// Try numeric comparison first
|
|
const aNum = parseFloat(aValue);
|
|
const bNum = parseFloat(bValue);
|
|
if (!isNaN(aNum) && !isNaN(bNum)) {
|
|
return newDir === 'asc' ? aNum - bNum : bNum - aNum;
|
|
}
|
|
|
|
// String comparison
|
|
const comparison = aValue.localeCompare(bValue);
|
|
return newDir === 'asc' ? comparison : -comparison;
|
|
});
|
|
|
|
// Re-append sorted rows
|
|
rows.forEach(row => this.tbody.appendChild(row));
|
|
}
|
|
|
|
async loadData() {
|
|
const params = new URLSearchParams({
|
|
page: this.currentPage.toString(),
|
|
per_page: this.perPage.toString(),
|
|
});
|
|
|
|
if (this.currentSearch) {
|
|
params.set('search', this.currentSearch);
|
|
}
|
|
|
|
if (this.currentSort) {
|
|
params.set('sort_by', this.currentSort.column);
|
|
params.set('sort_dir', this.currentSort.direction);
|
|
}
|
|
|
|
const url = `${this.apiEndpoint}?${params}`;
|
|
|
|
try {
|
|
this.setLoadingState(true);
|
|
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
this.renderRows(result.data);
|
|
|
|
if (this.paginated && result.pagination) {
|
|
this.renderPagination(result.pagination);
|
|
}
|
|
} else {
|
|
this.showError(result.error || 'Failed to load data');
|
|
}
|
|
} catch (error) {
|
|
console.error('AdminDataTable: Failed to load data', error);
|
|
console.error('AdminDataTable: URL was', url);
|
|
|
|
// Fallback to client-side sorting if API fails
|
|
if (this.currentSort && this.tbody.children.length > 0) {
|
|
console.warn('AdminDataTable: API failed, falling back to client-side sorting');
|
|
const th = this.thead.querySelector(`th[data-column="${this.currentSort.column}"]`);
|
|
if (th) {
|
|
this.sortByColumnClientSide(this.currentSort.column, th);
|
|
} else {
|
|
this.showError('Failed to load data. Please refresh the page.');
|
|
}
|
|
} else {
|
|
this.showError('Failed to load data');
|
|
}
|
|
} finally {
|
|
this.setLoadingState(false);
|
|
}
|
|
}
|
|
|
|
renderRows(data) {
|
|
if (!data || data.length === 0) {
|
|
this.tbody.innerHTML = this.getEmptyRow();
|
|
return;
|
|
}
|
|
|
|
// Get column keys from header
|
|
const columnKeys = this.getColumnKeys();
|
|
|
|
this.tbody.innerHTML = data.map(item => {
|
|
const cells = columnKeys.map(key => {
|
|
const value = item[key] !== undefined ? item[key] : '';
|
|
return `<td>${this.escapeHtml(value)}</td>`;
|
|
}).join('');
|
|
|
|
return `<tr data-id="${item.id || ''}">${cells}</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
renderPagination(pagination) {
|
|
const container = this.getPaginationContainer();
|
|
if (!container) return;
|
|
|
|
const { page, pages, total } = pagination;
|
|
|
|
if (pages <= 1) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const links = [];
|
|
|
|
// Previous
|
|
if (page > 1) {
|
|
links.push(`<a href="#" data-page="${page - 1}" class="page-link">Previous</a>`);
|
|
}
|
|
|
|
// Page numbers
|
|
for (let i = 1; i <= pages; i++) {
|
|
if (i === page) {
|
|
links.push(`<span class="page-link active">${i}</span>`);
|
|
} else {
|
|
links.push(`<a href="#" data-page="${i}" class="page-link">${i}</a>`);
|
|
}
|
|
}
|
|
|
|
// Next
|
|
if (page < pages) {
|
|
links.push(`<a href="#" data-page="${page + 1}" class="page-link">Next</a>`);
|
|
}
|
|
|
|
container.innerHTML = `
|
|
<div class="pagination">
|
|
${links.join('')}
|
|
<span class="pagination-info">Total: ${total}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
setLoadingState(loading) {
|
|
if (loading) {
|
|
this.element.classList.add('loading');
|
|
this.tbody.style.opacity = '0.5';
|
|
} else {
|
|
this.element.classList.remove('loading');
|
|
this.tbody.style.opacity = '1';
|
|
}
|
|
}
|
|
|
|
showError(message) {
|
|
const columnCount = this.getColumnKeys().length;
|
|
this.tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="${columnCount}" class="error-message">
|
|
${this.escapeHtml(message)}
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
|
|
getEmptyRow() {
|
|
const columnCount = this.getColumnKeys().length;
|
|
return `
|
|
<tr>
|
|
<td colspan="${columnCount}" class="empty-message">
|
|
No data available
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
|
|
getColumnKeys() {
|
|
if (!this.thead) return [];
|
|
|
|
const headers = this.thead.querySelectorAll('th');
|
|
return Array.from(headers).map((th, index) =>
|
|
th.dataset.column || this.getColumnKeyFromIndex(index)
|
|
).filter(Boolean);
|
|
}
|
|
|
|
getColumnKeyFromIndex(index) {
|
|
// Fallback: use column index as key
|
|
return `col_${index}`;
|
|
}
|
|
|
|
getSearchInput() {
|
|
return document.querySelector(`[data-table-search="${this.resource}"]`);
|
|
}
|
|
|
|
getPaginationContainer() {
|
|
return document.querySelector(`[data-table-pagination="${this.resource}"]`) ||
|
|
this.element.parentElement.querySelector('.pagination-container');
|
|
}
|
|
|
|
escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = String(str);
|
|
return div.innerHTML;
|
|
}
|
|
|
|
debounce(func, wait) {
|
|
let timeout;
|
|
return (...args) => {
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(() => func.apply(this, args), wait);
|
|
};
|
|
}
|
|
}
|
|
|
|
// Auto-initialize all admin data tables
|
|
// Initialize tables with API endpoints (AJAX loading)
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
document.querySelectorAll('[data-resource][data-api-endpoint]').forEach(table => {
|
|
new AdminDataTable(table).init();
|
|
});
|
|
|
|
// Also initialize tables with sortable attribute but without API endpoint (client-side sorting)
|
|
document.querySelectorAll('table.admin-table[data-sortable="true"]').forEach(table => {
|
|
if (!table.dataset.apiEndpoint) {
|
|
// Create a minimal AdminDataTable instance for client-side sorting
|
|
const dataTable = new AdminDataTable(table);
|
|
dataTable.sortable = true;
|
|
dataTable.init();
|
|
}
|
|
});
|
|
});
|