- Move 12 markdown files from root to docs/ subdirectories - Organize documentation by category: • docs/troubleshooting/ (1 file) - Technical troubleshooting guides • docs/deployment/ (4 files) - Deployment and security documentation • docs/guides/ (3 files) - Feature-specific guides • docs/planning/ (4 files) - Planning and improvement proposals Root directory cleanup: - Reduced from 16 to 4 markdown files in root - Only essential project files remain: • CLAUDE.md (AI instructions) • README.md (Main project readme) • CLEANUP_PLAN.md (Current cleanup plan) • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements) This improves: ✅ Documentation discoverability ✅ Logical organization by purpose ✅ Clean root directory ✅ Better maintainability
296 lines
8.5 KiB
JavaScript
296 lines
8.5 KiB
JavaScript
import { Module } from '../core/module.js';
|
|
|
|
/**
|
|
* 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 extends Module {
|
|
constructor(element) {
|
|
super(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() {
|
|
if (!this.apiEndpoint) {
|
|
console.warn('AdminDataTable: No API endpoint configured');
|
|
return;
|
|
}
|
|
|
|
this.tbody = this.element.querySelector('tbody');
|
|
this.thead = this.element.querySelector('thead');
|
|
|
|
if (!this.tbody) {
|
|
console.warn('AdminDataTable: No tbody found');
|
|
return;
|
|
}
|
|
|
|
this.setupEventListeners();
|
|
|
|
// Only load data if table is initially empty
|
|
if (this.tbody.children.length === 0) {
|
|
await this.loadData();
|
|
}
|
|
}
|
|
|
|
setupEventListeners() {
|
|
// Sortable columns
|
|
if (this.sortable && this.thead) {
|
|
this.thead.querySelectorAll('th').forEach((th, index) => {
|
|
const columnKey = th.dataset.column || this.getColumnKeyFromIndex(index);
|
|
|
|
if (columnKey) {
|
|
th.style.cursor = 'pointer';
|
|
th.classList.add('sortable');
|
|
|
|
th.addEventListener('click', () => {
|
|
this.sortByColumn(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
|
|
await this.loadData();
|
|
}
|
|
|
|
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);
|
|
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);
|
|
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
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
document.querySelectorAll('[data-resource][data-api-endpoint]').forEach(table => {
|
|
new AdminDataTable(table).init();
|
|
});
|
|
});
|