mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-08 20:51:50 -06:00
feat: Add enhanced table features with sorting, pagination, and column visibility
Implement comprehensive table enhancements across all data tables in the application: - Add sortable columns with visual indicators (up/down arrows) - Implement client-side pagination with configurable page size (10/25/50/100) - Add column visibility toggles with dropdown menu - Enable sticky headers that stick to top on scroll - Save user preferences (page size, visible columns) in localStorage - Support numeric, date, and text sorting with proper formatting Features: - Visual sort indicators show active column and direction - Pagination controls with page size selector and navigation buttons - Column visibility button integrated into existing toolbars - Sticky headers with shadow effect when active - Responsive design with mobile-friendly pagination - Dark mode support throughout Technical implementation: - Created data-tables-enhanced.js module with DataTableEnhanced class - Created data-tables-enhanced.css for all styling - Auto-initializes tables with data-table-enhanced attribute or table-zebra class - Properly integrates with existing Finance category table toolbars - Handles checkbox columns and special table structures Updated templates: - tasks/list.html, projects/list.html, clients/list.html - invoices/list.html, expenses/list.html, payments/list.html - mileage/list.html, per_diem/list.html, main/search.html All tables now provide consistent, enhanced user experience with improved data navigation and viewing options.
This commit is contained in:
260
app/static/data-tables-enhanced.css
Normal file
260
app/static/data-tables-enhanced.css
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Data Tables Enhanced Styles
|
||||
* Sticky headers, sort indicators, pagination, and column visibility
|
||||
*/
|
||||
|
||||
/* Sticky Header Styles */
|
||||
.table-scroll-container {
|
||||
position: relative;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table thead.sticky-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: white;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.dark table thead.sticky-header {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
table thead.sticky-header.sticky-active {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark table thead.sticky-header.sticky-active {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Sortable Column Styles */
|
||||
table th.sortable-column {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
table th.sortable-column:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dark table th.sortable-column:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
table th.sortable-column:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Sort Indicator */
|
||||
.sort-indicator {
|
||||
display: inline-block;
|
||||
margin-left: 0.5rem;
|
||||
color: #9ca3af;
|
||||
font-size: 0.75rem;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.dark .sort-indicator {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.sort-indicator.active {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.dark .sort-indicator.active {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
table th.sortable-column:hover .sort-indicator {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.dark table th.sortable-column:hover .sort-indicator {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
/* Column Visibility Dropdown */
|
||||
.column-visibility-dropdown {
|
||||
max-height: 20rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.column-visibility-dropdown label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.column-visibility-dropdown label:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.dark .column-visibility-dropdown label:hover {
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
/* Pagination Styles */
|
||||
.table-pagination-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.dark .table-pagination-container {
|
||||
border-top-color: #4b5563;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
background: white;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.dark .pagination-btn {
|
||||
border-color: #4b5563;
|
||||
background: #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.dark .pagination-btn:hover:not(:disabled) {
|
||||
background: #374151;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.dark .pagination-btn.active {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
/* Page Size Selector */
|
||||
.table-page-size-select {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.dark .table-page-size-select {
|
||||
border-color: #4b5563;
|
||||
background: #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.table-page-size-select:hover {
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.dark .table-page-size-select:hover {
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.table-page-size-select:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Table Toolbar */
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.table-toolbar-left,
|
||||
.table-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.table-pagination-container {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.table-pagination-container > div:first-child {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-pagination-container > div:last-child {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility improvements */
|
||||
table th.sortable-column[aria-label] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
table th.sortable-column:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: -2px;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Smooth transitions for column visibility */
|
||||
table th,
|
||||
table td {
|
||||
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||
}
|
||||
|
||||
table th[style*="display: none"],
|
||||
table td[style*="display: none"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
611
app/static/data-tables-enhanced.js
Normal file
611
app/static/data-tables-enhanced.js
Normal file
@@ -0,0 +1,611 @@
|
||||
/**
|
||||
* Data Tables Enhanced
|
||||
* Adds sortable columns, pagination, column visibility, and sticky headers to all tables
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
class DataTableEnhanced {
|
||||
constructor(table, options = {}) {
|
||||
this.table = table;
|
||||
this.tableId = table.id || `table-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
if (!table.id) table.id = this.tableId;
|
||||
|
||||
this.options = {
|
||||
sortable: options.sortable !== false,
|
||||
pagination: options.pagination !== false,
|
||||
pageSize: options.pageSize || 10,
|
||||
pageSizeOptions: options.pageSizeOptions || [10, 25, 50, 100],
|
||||
columnVisibility: options.columnVisibility !== false,
|
||||
stickyHeader: options.stickyHeader !== false,
|
||||
storageKey: options.storageKey || `table-${this.tableId}`,
|
||||
...options
|
||||
};
|
||||
|
||||
this.currentPage = 1;
|
||||
this.pageSize = this.options.pageSize;
|
||||
this.sortColumn = null;
|
||||
this.sortDirection = 'asc';
|
||||
this.visibleColumns = new Set();
|
||||
this.originalData = [];
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Extract data from table
|
||||
this.extractData();
|
||||
|
||||
// Load saved preferences
|
||||
this.loadPreferences();
|
||||
|
||||
// Initialize features
|
||||
if (this.options.sortable) this.initSorting();
|
||||
if (this.options.columnVisibility) this.initColumnVisibility();
|
||||
if (this.options.stickyHeader) this.initStickyHeader();
|
||||
if (this.options.pagination) this.initPagination();
|
||||
|
||||
// Apply initial state
|
||||
this.render();
|
||||
}
|
||||
|
||||
extractData() {
|
||||
const tbody = this.table.querySelector('tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
this.originalData = rows.map(row => ({
|
||||
element: row,
|
||||
cells: Array.from(row.querySelectorAll('td')),
|
||||
values: Array.from(row.querySelectorAll('td')).map(cell => {
|
||||
// Get sortable value (check for data-sort attribute)
|
||||
const sortValue = cell.getAttribute('data-sort');
|
||||
if (sortValue !== null) return sortValue;
|
||||
// Otherwise use text content
|
||||
return cell.textContent.trim();
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
loadPreferences() {
|
||||
try {
|
||||
const saved = localStorage.getItem(this.options.storageKey);
|
||||
if (saved) {
|
||||
const prefs = JSON.parse(saved);
|
||||
if (prefs.pageSize) this.pageSize = prefs.pageSize;
|
||||
if (prefs.visibleColumns) {
|
||||
this.visibleColumns = new Set(prefs.visibleColumns);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load table preferences', e);
|
||||
}
|
||||
}
|
||||
|
||||
savePreferences() {
|
||||
try {
|
||||
const prefs = {
|
||||
pageSize: this.pageSize,
|
||||
visibleColumns: Array.from(this.visibleColumns)
|
||||
};
|
||||
localStorage.setItem(this.options.storageKey, JSON.stringify(prefs));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save table preferences', e);
|
||||
}
|
||||
}
|
||||
|
||||
initSorting() {
|
||||
const thead = this.table.querySelector('thead');
|
||||
if (!thead) return;
|
||||
|
||||
const headers = Array.from(thead.querySelectorAll('th'));
|
||||
|
||||
headers.forEach((header, index) => {
|
||||
// Check if column is sortable (has data-sortable attribute or class)
|
||||
const isSortable = header.hasAttribute('data-sortable') ||
|
||||
header.classList.contains('sortable') ||
|
||||
!header.classList.contains('no-sort');
|
||||
|
||||
if (!isSortable) return;
|
||||
|
||||
// Skip checkbox columns
|
||||
if (header.querySelector('input[type="checkbox"]')) return;
|
||||
|
||||
header.classList.add('sortable-column');
|
||||
header.style.cursor = 'pointer';
|
||||
header.setAttribute('role', 'button');
|
||||
header.setAttribute('tabindex', '0');
|
||||
header.setAttribute('aria-label', `Sort by ${header.textContent.trim()}`);
|
||||
|
||||
// Add sort indicator container
|
||||
const indicator = document.createElement('span');
|
||||
indicator.className = 'sort-indicator';
|
||||
indicator.innerHTML = '<i class="fas fa-sort"></i>';
|
||||
header.appendChild(indicator);
|
||||
|
||||
// Click handler
|
||||
header.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.sort(index);
|
||||
});
|
||||
|
||||
// Keyboard handler
|
||||
header.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this.sort(index);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
sort(columnIndex) {
|
||||
// Toggle sort direction if same column
|
||||
if (this.sortColumn === columnIndex) {
|
||||
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sortColumn = columnIndex;
|
||||
this.sortDirection = 'asc';
|
||||
}
|
||||
|
||||
// Update visual indicators
|
||||
this.updateSortIndicators();
|
||||
|
||||
// Sort data
|
||||
this.originalData.sort((a, b) => {
|
||||
const aVal = a.values[columnIndex] || '';
|
||||
const bVal = b.values[columnIndex] || '';
|
||||
|
||||
// Try numeric sort
|
||||
const aNum = parseFloat(aVal.replace(/[^0-9.-]/g, ''));
|
||||
const bNum = parseFloat(bVal.replace(/[^0-9.-]/g, ''));
|
||||
|
||||
let comparison = 0;
|
||||
if (!isNaN(aNum) && !isNaN(bNum)) {
|
||||
comparison = aNum - bNum;
|
||||
} else {
|
||||
// Date sorting (try to parse as date)
|
||||
const aDate = new Date(aVal);
|
||||
const bDate = new Date(bVal);
|
||||
if (!isNaN(aDate.getTime()) && !isNaN(bDate.getTime())) {
|
||||
comparison = aDate - bDate;
|
||||
} else {
|
||||
// String sort
|
||||
comparison = aVal.localeCompare(bVal, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
// Reset to first page
|
||||
this.currentPage = 1;
|
||||
this.render();
|
||||
}
|
||||
|
||||
updateSortIndicators() {
|
||||
const headers = Array.from(this.table.querySelectorAll('thead th'));
|
||||
headers.forEach((header, index) => {
|
||||
const indicator = header.querySelector('.sort-indicator');
|
||||
if (!indicator) return;
|
||||
|
||||
if (index === this.sortColumn) {
|
||||
indicator.innerHTML = this.sortDirection === 'asc'
|
||||
? '<i class="fas fa-sort-up"></i>'
|
||||
: '<i class="fas fa-sort-down"></i>';
|
||||
indicator.classList.add('active');
|
||||
} else {
|
||||
indicator.innerHTML = '<i class="fas fa-sort"></i>';
|
||||
indicator.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initColumnVisibility() {
|
||||
// Create column visibility toggle button
|
||||
const tableContainer = this.table.closest('.bg-card-light, .bg-card-dark, .card, .table-container') ||
|
||||
this.table.parentElement;
|
||||
|
||||
// Look for existing toolbar (Finance tables have a flex toolbar with buttons before the table)
|
||||
const tableWrapper = this.table.closest('.overflow-x-auto, .table-responsive') || this.table;
|
||||
let existingToolbarRight = null;
|
||||
|
||||
// Check if there's an existing toolbar div before the table wrapper
|
||||
if (tableWrapper.parentElement) {
|
||||
let sibling = tableWrapper.previousElementSibling;
|
||||
while (sibling) {
|
||||
// Look for the flex toolbar div that contains buttons
|
||||
if (sibling.classList.contains('flex') &&
|
||||
sibling.classList.contains('justify-between') &&
|
||||
sibling.classList.contains('items-center')) {
|
||||
// Find the right side container (has flex items-center gap-2)
|
||||
existingToolbarRight = sibling.querySelector('div.flex.items-center.gap-2');
|
||||
if (!existingToolbarRight) {
|
||||
// If no right container found, use the sibling itself
|
||||
existingToolbarRight = sibling;
|
||||
}
|
||||
break;
|
||||
}
|
||||
sibling = sibling.previousElementSibling;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for table-toolbar class as fallback
|
||||
let toolbar = existingToolbarRight || tableContainer.querySelector('.table-toolbar');
|
||||
|
||||
if (!toolbar) {
|
||||
// Create new toolbar
|
||||
toolbar = document.createElement('div');
|
||||
toolbar.className = 'table-toolbar';
|
||||
if (tableWrapper.parentElement) {
|
||||
tableWrapper.parentElement.insertBefore(toolbar, tableWrapper);
|
||||
} else {
|
||||
tableContainer.insertBefore(toolbar, this.table);
|
||||
}
|
||||
}
|
||||
|
||||
// Add column visibility button if not exists
|
||||
if (!tableContainer.querySelector('.column-visibility-btn')) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'column-visibility-btn px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center gap-2';
|
||||
btn.innerHTML = '<i class="fas fa-columns"></i> <span>Columns</span>';
|
||||
btn.setAttribute('aria-label', 'Toggle column visibility');
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'column-visibility-dropdown hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50 p-2';
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleColumnVisibilityDropdown(dropdown);
|
||||
});
|
||||
|
||||
// Position dropdown
|
||||
const btnContainer = document.createElement('div');
|
||||
btnContainer.className = 'relative inline-block';
|
||||
btnContainer.appendChild(btn);
|
||||
btnContainer.appendChild(dropdown);
|
||||
|
||||
// If existing toolbar (Finance tables), append to the right side container
|
||||
if (existingToolbarRight) {
|
||||
// Insert at the beginning of the button group (before Export and Bulk Actions)
|
||||
existingToolbarRight.insertBefore(btnContainer, existingToolbarRight.firstChild);
|
||||
} else {
|
||||
// New toolbar - create proper structure
|
||||
const toolbarRight = toolbar.querySelector('.table-toolbar-right') || toolbar;
|
||||
if (!toolbar.querySelector('.table-toolbar-right')) {
|
||||
toolbar.style.display = 'flex';
|
||||
toolbar.style.justifyContent = 'space-between';
|
||||
toolbar.style.alignItems = 'center';
|
||||
toolbar.style.marginBottom = '1rem';
|
||||
}
|
||||
toolbarRight.appendChild(btnContainer);
|
||||
}
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!btnContainer.contains(e.target)) {
|
||||
dropdown.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Populate dropdown
|
||||
this.populateColumnVisibilityDropdown(dropdown);
|
||||
}
|
||||
}
|
||||
|
||||
populateColumnVisibilityDropdown(dropdown) {
|
||||
const headers = Array.from(this.table.querySelectorAll('thead th'));
|
||||
|
||||
// Create mapping of visible headers to their original indices
|
||||
const columnMap = [];
|
||||
headers.forEach((header, index) => {
|
||||
if (!header.querySelector('input[type="checkbox"]')) {
|
||||
columnMap.push({ header, index });
|
||||
}
|
||||
});
|
||||
|
||||
dropdown.innerHTML = columnMap.map(({ header, index }) => {
|
||||
const isVisible = !this.visibleColumns.has(index) || this.visibleColumns.size === 0;
|
||||
const headerText = header.textContent.trim().replace(/\s+/g, ' ');
|
||||
return `
|
||||
<label class="flex items-center gap-2 px-2 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded cursor-pointer">
|
||||
<input type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
data-column="${index}"
|
||||
${isVisible ? 'checked' : ''}>
|
||||
<span class="text-sm">${headerText}</span>
|
||||
</label>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Bind checkbox events
|
||||
dropdown.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', (e) => {
|
||||
const columnIndex = parseInt(e.target.getAttribute('data-column'));
|
||||
this.toggleColumn(columnIndex, e.target.checked);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
toggleColumnVisibilityDropdown(dropdown) {
|
||||
dropdown.classList.toggle('hidden');
|
||||
if (!dropdown.classList.contains('hidden')) {
|
||||
this.populateColumnVisibilityDropdown(dropdown);
|
||||
}
|
||||
}
|
||||
|
||||
toggleColumn(columnIndex, show) {
|
||||
const headers = Array.from(this.table.querySelectorAll('thead th'));
|
||||
const rows = Array.from(this.table.querySelectorAll('tbody tr'));
|
||||
|
||||
if (show) {
|
||||
this.visibleColumns.delete(columnIndex);
|
||||
} else {
|
||||
this.visibleColumns.add(columnIndex);
|
||||
}
|
||||
|
||||
headers[columnIndex].style.display = show ? '' : 'none';
|
||||
rows.forEach(row => {
|
||||
const cells = Array.from(row.querySelectorAll('td'));
|
||||
if (cells[columnIndex]) {
|
||||
cells[columnIndex].style.display = show ? '' : 'none';
|
||||
}
|
||||
});
|
||||
|
||||
this.savePreferences();
|
||||
}
|
||||
|
||||
initStickyHeader() {
|
||||
const thead = this.table.querySelector('thead');
|
||||
if (!thead) return;
|
||||
|
||||
// Add sticky header class
|
||||
thead.classList.add('sticky-header');
|
||||
|
||||
// Add scroll listener to table container
|
||||
const tableWrapper = this.table.closest('.table-responsive, .bg-card-light, .bg-card-dark') ||
|
||||
this.table.parentElement;
|
||||
|
||||
// Create wrapper if needed
|
||||
if (!tableWrapper.classList.contains('table-scroll-container')) {
|
||||
const scrollContainer = document.createElement('div');
|
||||
scrollContainer.className = 'table-scroll-container';
|
||||
scrollContainer.style.maxHeight = '70vh';
|
||||
scrollContainer.style.overflowY = 'auto';
|
||||
|
||||
this.table.parentNode.insertBefore(scrollContainer, this.table);
|
||||
scrollContainer.appendChild(this.table);
|
||||
}
|
||||
|
||||
// Update sticky header on scroll
|
||||
const scrollContainer = this.table.closest('.table-scroll-container') || tableWrapper;
|
||||
scrollContainer.addEventListener('scroll', () => {
|
||||
this.updateStickyHeader(scrollContainer);
|
||||
});
|
||||
}
|
||||
|
||||
updateStickyHeader(container) {
|
||||
const thead = this.table.querySelector('thead');
|
||||
if (!thead) return;
|
||||
|
||||
if (container.scrollTop > 0) {
|
||||
thead.classList.add('sticky-active');
|
||||
} else {
|
||||
thead.classList.remove('sticky-active');
|
||||
}
|
||||
}
|
||||
|
||||
initPagination() {
|
||||
// Create pagination controls
|
||||
const tableContainer = this.table.closest('.bg-card-light, .bg-card-dark, .card') ||
|
||||
this.table.parentElement;
|
||||
|
||||
let paginationContainer = tableContainer.querySelector('.table-pagination-container');
|
||||
if (!paginationContainer) {
|
||||
paginationContainer = document.createElement('div');
|
||||
paginationContainer.className = 'table-pagination-container flex items-center justify-between mt-4';
|
||||
// Insert after table
|
||||
const tableWrapper = this.table.closest('.overflow-x-auto, .table-responsive') || this.table.parentElement;
|
||||
if (tableWrapper && tableWrapper.nextSibling) {
|
||||
tableWrapper.parentNode.insertBefore(paginationContainer, tableWrapper.nextSibling);
|
||||
} else {
|
||||
this.table.parentNode.appendChild(paginationContainer);
|
||||
}
|
||||
}
|
||||
|
||||
this.paginationContainer = paginationContainer;
|
||||
this.renderPagination();
|
||||
}
|
||||
|
||||
renderPagination() {
|
||||
if (!this.paginationContainer) return;
|
||||
|
||||
const totalItems = this.originalData.length;
|
||||
const totalPages = Math.ceil(totalItems / this.pageSize);
|
||||
const start = (this.currentPage - 1) * this.pageSize + 1;
|
||||
const end = Math.min(this.currentPage * this.pageSize, totalItems);
|
||||
|
||||
// Page size selector
|
||||
const pageSizeSelect = `
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-text-muted-light dark:text-text-muted-dark">Show:</label>
|
||||
<select class="table-page-size-select px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-background-light dark:bg-background-dark">
|
||||
${this.options.pageSizeOptions.map(size =>
|
||||
`<option value="${size}" ${size === this.pageSize ? 'selected' : ''}>${size}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Pagination info
|
||||
const paginationInfo = `
|
||||
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">
|
||||
Showing ${start} to ${end} of ${totalItems} entries
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Pagination buttons
|
||||
const paginationButtons = this.renderPaginationButtons(totalPages);
|
||||
|
||||
this.paginationContainer.innerHTML = `
|
||||
${pageSizeSelect}
|
||||
<div class="flex items-center gap-2">
|
||||
${paginationInfo}
|
||||
${paginationButtons}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Bind page size change
|
||||
const pageSizeSelectEl = this.paginationContainer.querySelector('.table-page-size-select');
|
||||
if (pageSizeSelectEl) {
|
||||
pageSizeSelectEl.addEventListener('change', (e) => {
|
||||
this.pageSize = parseInt(e.target.value);
|
||||
this.currentPage = 1;
|
||||
this.savePreferences();
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
// Bind pagination button clicks
|
||||
this.paginationContainer.querySelectorAll('.pagination-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const page = parseInt(e.target.getAttribute('data-page'));
|
||||
if (!isNaN(page) && page >= 1 && page <= totalPages) {
|
||||
this.goToPage(page);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderPaginationButtons(totalPages) {
|
||||
if (totalPages <= 1) return '';
|
||||
|
||||
let buttons = '';
|
||||
|
||||
// Previous button
|
||||
buttons = `
|
||||
<button class="pagination-btn px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
data-page="${this.currentPage - 1}"
|
||||
${this.currentPage === 1 ? 'disabled' : ''}>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Page numbers
|
||||
const maxVisible = 5;
|
||||
let startPage = Math.max(1, this.currentPage - Math.floor(maxVisible / 2));
|
||||
let endPage = Math.min(totalPages, startPage + maxVisible - 1);
|
||||
|
||||
if (endPage - startPage < maxVisible - 1) {
|
||||
startPage = Math.max(1, endPage - maxVisible + 1);
|
||||
}
|
||||
|
||||
if (startPage > 1) {
|
||||
buttons += `<button class="pagination-btn px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700" data-page="1">1</button>`;
|
||||
if (startPage > 2) {
|
||||
buttons += `<span class="px-2 text-text-muted-light dark:text-text-muted-dark">...</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
buttons += `
|
||||
<button class="pagination-btn px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded ${
|
||||
i === this.currentPage
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}"
|
||||
data-page="${i}">
|
||||
${i}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
if (endPage < totalPages) {
|
||||
if (endPage < totalPages - 1) {
|
||||
buttons += `<span class="px-2 text-text-muted-light dark:text-text-muted-dark">...</span>`;
|
||||
}
|
||||
buttons += `<button class="pagination-btn px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700" data-page="${totalPages}">${totalPages}</button>`;
|
||||
}
|
||||
|
||||
// Next button
|
||||
buttons += `
|
||||
<button class="pagination-btn px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
data-page="${this.currentPage + 1}"
|
||||
${this.currentPage === totalPages ? 'disabled' : ''}>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
return buttons;
|
||||
}
|
||||
|
||||
goToPage(page) {
|
||||
const totalPages = Math.ceil(this.originalData.length / this.pageSize);
|
||||
if (page < 1 || page > totalPages) return;
|
||||
|
||||
this.currentPage = page;
|
||||
this.render();
|
||||
|
||||
// Scroll to top of table
|
||||
this.table.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
render() {
|
||||
const tbody = this.table.querySelector('tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
// Hide all rows
|
||||
this.originalData.forEach(row => {
|
||||
row.element.style.display = 'none';
|
||||
});
|
||||
|
||||
// Calculate pagination
|
||||
const start = (this.currentPage - 1) * this.pageSize;
|
||||
const end = start + this.pageSize;
|
||||
const visibleRows = this.originalData.slice(start, end);
|
||||
|
||||
// Show visible rows
|
||||
visibleRows.forEach(row => {
|
||||
row.element.style.display = '';
|
||||
});
|
||||
|
||||
// Update pagination UI
|
||||
if (this.options.pagination) {
|
||||
this.renderPagination();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize tables with data-table-enhanced attribute
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Find all tables with data-table-enhanced attribute or class
|
||||
const tables = document.querySelectorAll('table[data-table-enhanced], table.table-enhanced, table.table-zebra');
|
||||
|
||||
tables.forEach((table, index) => {
|
||||
// Skip if already initialized
|
||||
if (table.dataset.enhancedTableInitialized) return;
|
||||
table.dataset.enhancedTableInitialized = 'true';
|
||||
|
||||
// Get options from data attributes
|
||||
const options = {
|
||||
sortable: table.dataset.sortable !== 'false',
|
||||
pagination: table.dataset.pagination !== 'false',
|
||||
pageSize: parseInt(table.dataset.pageSize) || 25,
|
||||
columnVisibility: table.dataset.columnVisibility !== 'false',
|
||||
stickyHeader: table.dataset.stickyHeader !== 'false',
|
||||
storageKey: table.dataset.storageKey || `table-${table.id || index}`
|
||||
};
|
||||
|
||||
new DataTableEnhanced(table, options);
|
||||
});
|
||||
});
|
||||
|
||||
// Export for manual initialization
|
||||
window.DataTableEnhanced = DataTableEnhanced;
|
||||
})();
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='form-bridge.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='form-validation.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='enhanced-ui.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='data-tables-enhanced.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='toast-notifications.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='keyboard-shortcuts.css') }}">
|
||||
<style>
|
||||
@@ -1073,6 +1074,7 @@
|
||||
document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeAllMenus(); });
|
||||
</script>
|
||||
|
||||
<script src="{{ url_for('static', filename='data-tables-enhanced.js') }}"></script>
|
||||
{% block scripts_extra %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-zebra w-full text-left">
|
||||
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
{% if current_user.is_admin %}
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto p-6 pt-4">
|
||||
<table class="table table-zebra w-full text-left">
|
||||
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
{% if current_user.is_admin %}
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-zebra w-full text-left">
|
||||
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
{% if current_user.is_admin %}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<div class="card-body">
|
||||
{% if entries and entries.items %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<table class="table table-hover table-zebra" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('Project') }}</th>
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto p-6 pt-4">
|
||||
<table class="table table-zebra w-full text-left">
|
||||
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
{% if current_user.is_admin %}
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto p-6 pt-4">
|
||||
<table class="table table-zebra w-full text-left">
|
||||
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
{% if current_user.is_admin %}
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto p-6 pt-4">
|
||||
<table class="table table-zebra w-full text-left">
|
||||
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
{% if current_user.is_admin %}
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-zebra w-full text-left">
|
||||
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
{% if current_user.is_admin %}
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-zebra w-full text-left">
|
||||
<table class="table table-zebra w-full text-left" data-table-enhanced data-page-size="25" data-sticky-header="true" data-column-visibility="true">
|
||||
<thead class="border-b border-border-light dark:border-border-dark">
|
||||
<tr>
|
||||
<th class="p-4 w-12">
|
||||
|
||||
Reference in New Issue
Block a user