mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-21 05:40:26 -05:00
624a43446d
- enhanced-ui, error-handling, keyboard-shortcuts, pwa, smart-notifications, toast-notifications - base.html layout and script includes
1527 lines
54 KiB
JavaScript
1527 lines
54 KiB
JavaScript
/**
|
|
* Enhanced UI JavaScript
|
|
* Comprehensive UX improvements for TimeTracker
|
|
*/
|
|
|
|
// ============================================
|
|
// ENHANCED TABLE FUNCTIONALITY
|
|
// ============================================
|
|
class EnhancedTable {
|
|
constructor(tableElement) {
|
|
this.table = tableElement;
|
|
this.selectedRows = new Set();
|
|
this.sortState = {};
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.table.classList.add('enhanced-table');
|
|
this.initSorting();
|
|
this.initBulkSelect();
|
|
this.initColumnResize();
|
|
this.initInlineEdit();
|
|
}
|
|
|
|
initSorting() {
|
|
const headers = this.table.querySelectorAll('thead th[data-sortable]');
|
|
headers.forEach((header, index) => {
|
|
header.classList.add('sortable');
|
|
header.addEventListener('click', () => this.sortColumn(index, header));
|
|
});
|
|
}
|
|
|
|
sortColumn(columnIndex, header) {
|
|
const tbody = this.table.querySelector('tbody');
|
|
const rows = Array.from(tbody.querySelectorAll('tr'));
|
|
|
|
// Determine sort direction
|
|
let direction = 'asc';
|
|
if (header.classList.contains('sorted-asc')) {
|
|
direction = 'desc';
|
|
}
|
|
|
|
// Clear all sort indicators
|
|
this.table.querySelectorAll('th').forEach(th => {
|
|
th.classList.remove('sorted-asc', 'sorted-desc');
|
|
});
|
|
|
|
// Add sort indicator
|
|
header.classList.add(`sorted-${direction}`);
|
|
|
|
// 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.replace(/[^0-9.-]/g, ''));
|
|
const bNum = parseFloat(bValue.replace(/[^0-9.-]/g, ''));
|
|
|
|
if (!isNaN(aNum) && !isNaN(bNum)) {
|
|
return direction === 'asc' ? aNum - bNum : bNum - aNum;
|
|
}
|
|
|
|
// String comparison
|
|
return direction === 'asc'
|
|
? aValue.localeCompare(bValue)
|
|
: bValue.localeCompare(aValue);
|
|
});
|
|
|
|
// Reorder rows
|
|
rows.forEach(row => tbody.appendChild(row));
|
|
}
|
|
|
|
initBulkSelect() {
|
|
const tbody = this.table.querySelector('tbody');
|
|
if (!tbody) return;
|
|
|
|
// Add bulk select checkbox to header
|
|
const thead = this.table.querySelector('thead tr');
|
|
const selectAllTh = document.createElement('th');
|
|
selectAllTh.className = 'px-4 py-3 w-12';
|
|
selectAllTh.innerHTML = '<input type="checkbox" class="select-all-checkbox rounded" />';
|
|
thead.insertBefore(selectAllTh, thead.firstChild);
|
|
|
|
// Add checkboxes to each row
|
|
tbody.querySelectorAll('tr').forEach((row, index) => {
|
|
const selectTd = document.createElement('td');
|
|
selectTd.className = 'px-4 py-3';
|
|
selectTd.innerHTML = `<input type="checkbox" class="row-checkbox rounded" data-row-index="${index}" />`;
|
|
row.insertBefore(selectTd, row.firstChild);
|
|
});
|
|
|
|
// Select all functionality
|
|
const selectAllCheckbox = thead.querySelector('.select-all-checkbox');
|
|
selectAllCheckbox?.addEventListener('change', (e) => {
|
|
const checkboxes = tbody.querySelectorAll('.row-checkbox');
|
|
checkboxes.forEach(cb => {
|
|
cb.checked = e.target.checked;
|
|
this.toggleRowSelection(cb.closest('tr'), e.target.checked);
|
|
});
|
|
this.updateBulkActionsBar();
|
|
});
|
|
|
|
// Individual row selection
|
|
tbody.querySelectorAll('.row-checkbox').forEach(checkbox => {
|
|
checkbox.addEventListener('change', (e) => {
|
|
this.toggleRowSelection(e.target.closest('tr'), e.target.checked);
|
|
this.updateBulkActionsBar();
|
|
});
|
|
});
|
|
}
|
|
|
|
toggleRowSelection(row, selected) {
|
|
if (selected) {
|
|
row.classList.add('selected');
|
|
this.selectedRows.add(row);
|
|
} else {
|
|
row.classList.remove('selected');
|
|
this.selectedRows.delete(row);
|
|
}
|
|
}
|
|
|
|
updateBulkActionsBar() {
|
|
const count = this.selectedRows.size;
|
|
let bar = document.querySelector('.bulk-actions-bar');
|
|
|
|
if (count > 0) {
|
|
if (!bar) {
|
|
bar = this.createBulkActionsBar();
|
|
document.body.appendChild(bar);
|
|
}
|
|
bar.querySelector('.selection-count').textContent = count;
|
|
setTimeout(() => bar.classList.add('show'), 10);
|
|
} else if (bar) {
|
|
bar.classList.remove('show');
|
|
setTimeout(() => bar.remove(), 300);
|
|
}
|
|
}
|
|
|
|
createBulkActionsBar() {
|
|
const bar = document.createElement('div');
|
|
bar.className = 'bulk-actions-bar';
|
|
bar.innerHTML = `
|
|
<span class="text-sm font-medium">
|
|
<span class="selection-count">0</span> items selected
|
|
</span>
|
|
<button class="px-3 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors" onclick="bulkDelete()">
|
|
<i class="fas fa-trash mr-1"></i> Delete
|
|
</button>
|
|
<button class="px-3 py-1.5 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors" onclick="bulkExport()">
|
|
<i class="fas fa-download mr-1"></i> Export
|
|
</button>
|
|
<button class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" onclick="clearSelection()">
|
|
Cancel
|
|
</button>
|
|
`;
|
|
return bar;
|
|
}
|
|
|
|
initColumnResize() {
|
|
const headers = this.table.querySelectorAll('thead th');
|
|
headers.forEach((header, index) => {
|
|
if (index === headers.length - 1) return; // Skip last column
|
|
|
|
const resizer = document.createElement('div');
|
|
resizer.className = 'column-resizer';
|
|
header.style.position = 'relative';
|
|
header.appendChild(resizer);
|
|
|
|
let startX, startWidth;
|
|
|
|
resizer.addEventListener('mousedown', (e) => {
|
|
startX = e.pageX;
|
|
startWidth = header.offsetWidth;
|
|
resizer.classList.add('resizing');
|
|
document.addEventListener('mousemove', resize);
|
|
document.addEventListener('mouseup', stopResize);
|
|
e.preventDefault();
|
|
});
|
|
|
|
const resize = (e) => {
|
|
const width = startWidth + (e.pageX - startX);
|
|
header.style.width = width + 'px';
|
|
};
|
|
|
|
const stopResize = () => {
|
|
resizer.classList.remove('resizing');
|
|
document.removeEventListener('mousemove', resize);
|
|
document.removeEventListener('mouseup', stopResize);
|
|
};
|
|
});
|
|
}
|
|
|
|
initInlineEdit() {
|
|
this.table.querySelectorAll('[data-editable]').forEach(cell => {
|
|
cell.style.cursor = 'pointer';
|
|
cell.addEventListener('dblclick', () => this.makeEditable(cell));
|
|
});
|
|
}
|
|
|
|
makeEditable(cell) {
|
|
const value = cell.textContent.trim();
|
|
const input = document.createElement('input');
|
|
input.type = 'text';
|
|
input.value = value;
|
|
input.className = 'inline-edit-input';
|
|
|
|
cell.textContent = '';
|
|
cell.appendChild(input);
|
|
input.focus();
|
|
input.select();
|
|
|
|
const save = () => {
|
|
const newValue = input.value;
|
|
cell.textContent = newValue;
|
|
// Trigger save event
|
|
const event = new CustomEvent('cellEdited', {
|
|
detail: { cell, oldValue: value, newValue }
|
|
});
|
|
this.table.dispatchEvent(event);
|
|
};
|
|
|
|
input.addEventListener('blur', save);
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') save();
|
|
if (e.key === 'Escape') {
|
|
cell.textContent = value;
|
|
}
|
|
});
|
|
}
|
|
|
|
getSelectedRowData() {
|
|
return Array.from(this.selectedRows).map(row => {
|
|
const cells = Array.from(row.cells).slice(1); // Skip checkbox column
|
|
return cells.map(cell => cell.textContent.trim());
|
|
});
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// LIVE SEARCH FUNCTIONALITY
|
|
// ============================================
|
|
class LiveSearch {
|
|
constructor(inputElement, options = {}) {
|
|
this.input = inputElement;
|
|
this.options = {
|
|
debounceMs: 300,
|
|
minChars: 2,
|
|
onSearch: null,
|
|
showResults: true,
|
|
...options
|
|
};
|
|
this.debounceTimer = null;
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
const container = document.createElement('div');
|
|
container.className = 'search-container relative';
|
|
this.input.parentNode.insertBefore(container, this.input);
|
|
container.appendChild(this.input);
|
|
|
|
// Add search icon
|
|
const icon = document.createElement('i');
|
|
icon.className = 'fas fa-search search-icon absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400';
|
|
container.appendChild(icon);
|
|
|
|
// Add clear button
|
|
const clearBtn = document.createElement('i');
|
|
clearBtn.className = 'fas fa-times search-clear absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 cursor-pointer';
|
|
container.appendChild(clearBtn);
|
|
|
|
// Add input padding
|
|
this.input.classList.add('search-input', 'pl-10', 'pr-10');
|
|
|
|
// Create results dropdown
|
|
if (this.options.showResults) {
|
|
this.resultsDropdown = document.createElement('div');
|
|
this.resultsDropdown.className = 'search-results-dropdown';
|
|
container.appendChild(this.resultsDropdown);
|
|
}
|
|
|
|
// Event listeners
|
|
this.input.addEventListener('input', (e) => this.handleInput(e));
|
|
clearBtn.addEventListener('click', () => this.clear());
|
|
|
|
// Show/hide clear button
|
|
this.input.addEventListener('input', () => {
|
|
clearBtn.classList.toggle('show', this.input.value.length > 0);
|
|
});
|
|
|
|
// Close dropdown on outside click
|
|
document.addEventListener('click', (e) => {
|
|
if (!container.contains(e.target) && this.resultsDropdown) {
|
|
this.resultsDropdown.classList.remove('show');
|
|
}
|
|
});
|
|
}
|
|
|
|
handleInput(e) {
|
|
clearTimeout(this.debounceTimer);
|
|
|
|
const query = e.target.value.trim();
|
|
|
|
if (query.length < this.options.minChars) {
|
|
if (this.resultsDropdown) {
|
|
this.resultsDropdown.classList.remove('show');
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.debounceTimer = setTimeout(() => {
|
|
if (this.options.onSearch) {
|
|
this.options.onSearch(query, (results) => {
|
|
if (this.options.showResults) {
|
|
this.displayResults(results);
|
|
}
|
|
});
|
|
}
|
|
}, this.options.debounceMs);
|
|
}
|
|
|
|
displayResults(results) {
|
|
if (!this.resultsDropdown) return;
|
|
|
|
if (results.length === 0) {
|
|
this.resultsDropdown.innerHTML = '<div class="p-4 text-center text-gray-500">No results found</div>';
|
|
} else {
|
|
this.resultsDropdown.innerHTML = results.map(result => `
|
|
<a href="${result.url}" class="search-result-item block">
|
|
<div class="font-medium text-gray-900 dark:text-gray-100">${result.title}</div>
|
|
${result.subtitle ? `<div class="text-sm text-gray-500">${result.subtitle}</div>` : ''}
|
|
</a>
|
|
`).join('');
|
|
}
|
|
|
|
this.resultsDropdown.classList.add('show');
|
|
}
|
|
|
|
clear() {
|
|
this.input.value = '';
|
|
this.input.focus();
|
|
if (this.resultsDropdown) {
|
|
this.resultsDropdown.classList.remove('show');
|
|
}
|
|
if (this.options.onSearch) {
|
|
this.options.onSearch('', () => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// FILTER MANAGEMENT
|
|
// ============================================
|
|
class FilterManager {
|
|
constructor(formElement) {
|
|
this.form = formElement;
|
|
this.activeFilters = new Map();
|
|
this.submitTimeout = null;
|
|
this.inputTimeouts = new Map();
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
// Create filter chips container
|
|
this.chipsContainer = document.createElement('div');
|
|
this.chipsContainer.className = 'filter-chips-container';
|
|
this.form.parentNode.insertBefore(this.chipsContainer, this.form.nextSibling);
|
|
|
|
// Monitor form changes - auto-submit on dropdown changes
|
|
this.form.querySelectorAll('select').forEach(select => {
|
|
select.addEventListener('change', () => {
|
|
this.updateFilters();
|
|
// Auto-submit on dropdown changes
|
|
this.submitForm();
|
|
});
|
|
});
|
|
|
|
// Monitor text input fields (search fields) - auto-submit with debouncing
|
|
this.inputTimeouts = new Map();
|
|
this.form.querySelectorAll('input[type="text"], input[type="search"]').forEach(input => {
|
|
input.addEventListener('input', (e) => {
|
|
// Update filter chips immediately for visual feedback
|
|
this.updateFilters();
|
|
|
|
// Debounce the actual form submission
|
|
const timeoutKey = input.name || input.id;
|
|
if (this.inputTimeouts.has(timeoutKey)) {
|
|
clearTimeout(this.inputTimeouts.get(timeoutKey));
|
|
}
|
|
|
|
// Submit after user stops typing (500ms delay)
|
|
const timeout = setTimeout(() => {
|
|
this.submitForm();
|
|
this.inputTimeouts.delete(timeoutKey);
|
|
}, 500);
|
|
|
|
this.inputTimeouts.set(timeoutKey, timeout);
|
|
});
|
|
|
|
// Also submit on Enter key
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
// Clear any pending timeout
|
|
const timeoutKey = input.name || input.id;
|
|
if (this.inputTimeouts.has(timeoutKey)) {
|
|
clearTimeout(this.inputTimeouts.get(timeoutKey));
|
|
this.inputTimeouts.delete(timeoutKey);
|
|
}
|
|
// Submit immediately
|
|
this.submitForm();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Listen to form submit - prevent default and use AJAX instead
|
|
this.form.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.updateFilters();
|
|
this.submitForm();
|
|
});
|
|
|
|
// Add quick filters
|
|
this.addQuickFilters();
|
|
|
|
// Initial render
|
|
this.updateFilters();
|
|
}
|
|
|
|
addQuickFilters() {
|
|
const quickFilters = this.form.dataset.quickFilters;
|
|
if (!quickFilters) return;
|
|
|
|
const filters = JSON.parse(quickFilters);
|
|
const quickFiltersDiv = document.createElement('div');
|
|
quickFiltersDiv.className = 'quick-filters';
|
|
|
|
filters.forEach(filter => {
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'quick-filter-btn';
|
|
btn.textContent = filter.label;
|
|
btn.addEventListener('click', () => this.applyQuickFilter(filter));
|
|
quickFiltersDiv.appendChild(btn);
|
|
});
|
|
|
|
this.form.parentNode.insertBefore(quickFiltersDiv, this.form);
|
|
}
|
|
|
|
applyQuickFilter(filter) {
|
|
Object.entries(filter.values).forEach(([key, value]) => {
|
|
const input = this.form.querySelector(`[name="${key}"]`);
|
|
if (input) {
|
|
if (input.type === 'checkbox') {
|
|
input.checked = value;
|
|
} else {
|
|
input.value = value;
|
|
}
|
|
}
|
|
});
|
|
this.form.dispatchEvent(new Event('submit', { bubbles: true }));
|
|
}
|
|
|
|
updateFilters() {
|
|
this.activeFilters.clear();
|
|
const formData = new FormData(this.form);
|
|
|
|
for (const [key, value] of formData.entries()) {
|
|
if (value && value !== 'all' && value !== '') {
|
|
const input = this.form.querySelector(`[name="${key}"]`);
|
|
const label = input?.labels?.[0]?.textContent || key;
|
|
this.activeFilters.set(key, { label, value });
|
|
}
|
|
}
|
|
|
|
this.renderChips();
|
|
}
|
|
|
|
renderChips() {
|
|
this.chipsContainer.innerHTML = '';
|
|
|
|
if (this.activeFilters.size === 0) {
|
|
this.chipsContainer.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
this.chipsContainer.style.display = 'flex';
|
|
|
|
this.activeFilters.forEach((filter, key) => {
|
|
const chip = document.createElement('span');
|
|
chip.className = 'inline-flex items-center px-3 py-1 rounded-full text-sm bg-primary/10 dark:bg-primary/20 text-primary border border-primary/20 dark:border-primary/30';
|
|
chip.innerHTML = `
|
|
<span class="font-medium">${filter.label}:</span>
|
|
<span class="ml-1">${filter.value}</span>
|
|
<button type="button" class="ml-2 hover:text-red-600 transition-colors" data-remove-filter="${key}">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
`;
|
|
this.chipsContainer.appendChild(chip);
|
|
});
|
|
|
|
// Add clear all button
|
|
if (this.activeFilters.size > 0) {
|
|
const clearAll = document.createElement('button');
|
|
clearAll.type = 'button';
|
|
clearAll.className = 'text-sm text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors';
|
|
clearAll.innerHTML = '<i class="fas fa-times-circle mr-1"></i> Clear all';
|
|
clearAll.addEventListener('click', () => this.clearAll());
|
|
this.chipsContainer.appendChild(clearAll);
|
|
}
|
|
|
|
// Add remove listeners
|
|
this.chipsContainer.querySelectorAll('[data-remove-filter]').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
const key = e.currentTarget.dataset.removeFilter;
|
|
this.removeFilter(key);
|
|
});
|
|
});
|
|
}
|
|
|
|
removeFilter(key) {
|
|
const input = this.form.querySelector(`[name="${key}"]`);
|
|
if (input) {
|
|
if (input.type === 'checkbox') {
|
|
input.checked = false;
|
|
} else if (input.tagName === 'SELECT') {
|
|
// For select elements, set to first option (usually "All" or empty)
|
|
if (input.options.length > 0) {
|
|
input.value = input.options[0].value;
|
|
} else {
|
|
input.value = '';
|
|
}
|
|
} else {
|
|
input.value = '';
|
|
}
|
|
// Update filters and submit
|
|
this.updateFilters();
|
|
this.submitForm();
|
|
}
|
|
}
|
|
|
|
clearAll() {
|
|
// Reset all form fields
|
|
this.form.reset();
|
|
|
|
// For select elements, ensure they're set to their default (first option)
|
|
this.form.querySelectorAll('select').forEach(select => {
|
|
if (select.options.length > 0) {
|
|
select.value = select.options[0].value;
|
|
}
|
|
});
|
|
|
|
// Explicitly set status to "all" to show all projects
|
|
const statusSelect = this.form.querySelector('[name="status"]');
|
|
if (statusSelect) {
|
|
statusSelect.value = 'all';
|
|
}
|
|
|
|
// Clear all text inputs
|
|
this.form.querySelectorAll('input[type="text"], input[type="search"]').forEach(input => {
|
|
input.value = '';
|
|
});
|
|
|
|
// Update filters and submit
|
|
this.updateFilters();
|
|
this.submitForm();
|
|
}
|
|
|
|
submitForm() {
|
|
// Ensure the form can be submitted (remove any disabled state from submit button)
|
|
const submitButton = this.form.querySelector('button[type="submit"]');
|
|
if (submitButton) {
|
|
submitButton.disabled = false;
|
|
submitButton.style.display = '';
|
|
submitButton.style.visibility = '';
|
|
submitButton.style.opacity = '';
|
|
}
|
|
|
|
// For GET forms (filter forms), use AJAX to avoid page reload
|
|
if (this.form.method.toUpperCase() === 'GET') {
|
|
// Use a small delay to prevent rapid-fire submissions
|
|
if (this.submitTimeout) {
|
|
clearTimeout(this.submitTimeout);
|
|
}
|
|
this.submitTimeout = setTimeout(() => {
|
|
this.submitViaAjax();
|
|
}, 100);
|
|
} else {
|
|
// For POST forms, dispatch submit event (validation will handle it)
|
|
this.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
|
}
|
|
}
|
|
|
|
submitViaAjax() {
|
|
// Build query string from form data
|
|
const formData = new FormData(this.form);
|
|
const params = new URLSearchParams();
|
|
|
|
// Check if this is the time entries form - handle it differently
|
|
const isTimeEntriesForm = this.form.id === 'timeEntriesFilterForm';
|
|
|
|
// Always include status - default to "all" if not set or empty (for projects page)
|
|
const statusSelect = this.form.querySelector('[name="status"]');
|
|
let statusValue = statusSelect ? statusSelect.value : null;
|
|
// If status is empty or null and we're not on time entries, default to "all"
|
|
if (!isTimeEntriesForm && (!statusValue || statusValue === '')) {
|
|
statusValue = 'all';
|
|
}
|
|
if (statusValue && statusValue !== '') {
|
|
params.append('status', statusValue);
|
|
}
|
|
|
|
// Process other form fields
|
|
for (const [key, value] of formData.entries()) {
|
|
// Skip status as we already handled it
|
|
if (key === 'status' && statusValue) {
|
|
continue;
|
|
}
|
|
// Include search if it has a value (trimmed)
|
|
else if (key === 'search') {
|
|
const trimmedValue = String(value || '').trim();
|
|
if (trimmedValue) {
|
|
params.append(key, trimmedValue);
|
|
}
|
|
}
|
|
// Include other fields if they have values
|
|
else if (value && String(value).trim() !== '') {
|
|
params.append(key, String(value).trim());
|
|
}
|
|
}
|
|
|
|
// Get the form action or current URL
|
|
const url = this.form.action || window.location.pathname;
|
|
const queryString = params.toString();
|
|
// Build full URL - for projects page, always include status parameter if query is empty
|
|
let fullUrl;
|
|
if (!isTimeEntriesForm && !queryString) {
|
|
fullUrl = `${url}?status=all`;
|
|
} else if (queryString) {
|
|
fullUrl = `${url}?${queryString}`;
|
|
} else {
|
|
fullUrl = url;
|
|
}
|
|
|
|
// Update URL without page reload
|
|
if (window.history && window.history.pushState) {
|
|
window.history.pushState({}, '', fullUrl);
|
|
}
|
|
|
|
// Detect container type - check for time entries first, then projects
|
|
const timeEntriesContainer = document.getElementById('timeEntriesListContainer');
|
|
const projectsContainer = document.getElementById('projectsListContainer');
|
|
const projectsWrapper = document.getElementById('projectsContainer');
|
|
const container = timeEntriesContainer || projectsContainer || projectsWrapper;
|
|
|
|
if (container) {
|
|
container.style.opacity = '0.5';
|
|
container.style.pointerEvents = 'none';
|
|
}
|
|
|
|
// Fetch filtered results via AJAX
|
|
fetch(fullUrl, {
|
|
method: 'GET',
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'Accept': 'text/html'
|
|
},
|
|
credentials: 'same-origin'
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error(`Network response was not ok: ${response.status} ${response.statusText}`);
|
|
}
|
|
return response.text();
|
|
})
|
|
.then(html => {
|
|
// Handle time entries container
|
|
if (timeEntriesContainer) {
|
|
const trimmedHtml = html.trim();
|
|
const tempDiv = document.createElement('div');
|
|
tempDiv.innerHTML = trimmedHtml;
|
|
|
|
const newContainer = tempDiv.querySelector('#timeEntriesListContainer');
|
|
|
|
if (newContainer) {
|
|
timeEntriesContainer.innerHTML = newContainer.innerHTML;
|
|
} else {
|
|
// Fallback: try to extract content using regex
|
|
const match = trimmedHtml.match(/<div[^>]*id=["']timeEntriesListContainer["'][^>]*>([\s\S]*?)<\/div>\s*$/);
|
|
if (match && match[1]) {
|
|
timeEntriesContainer.innerHTML = match[1];
|
|
} else {
|
|
timeEntriesContainer.innerHTML = html;
|
|
}
|
|
}
|
|
|
|
// Re-initialize bulk actions after content update
|
|
if (typeof updateBulkActions === 'function') {
|
|
updateBulkActions();
|
|
}
|
|
|
|
// Update filter chips after content update
|
|
this.updateFilters();
|
|
return;
|
|
}
|
|
|
|
// Handle projects container (original logic)
|
|
// Update the projects list container
|
|
let targetContainer = projectsContainer;
|
|
|
|
if (!targetContainer && projectsWrapper) {
|
|
// If we don't have the inner container, try to find or create it
|
|
let innerContainer = projectsWrapper.querySelector('#projectsListContainer');
|
|
if (!innerContainer) {
|
|
innerContainer = document.createElement('div');
|
|
innerContainer.id = 'projectsListContainer';
|
|
projectsWrapper.insertBefore(innerContainer, projectsWrapper.firstChild);
|
|
}
|
|
if (innerContainer) {
|
|
targetContainer = innerContainer;
|
|
}
|
|
}
|
|
|
|
if (targetContainer) {
|
|
const trimmedHtml = html.trim();
|
|
|
|
// The partial template returns: <div id="projectsListContainer">...</div>
|
|
// Extract the innerHTML from this div using a simple approach
|
|
|
|
// Create a temporary container and parse the HTML
|
|
const tempDiv = document.createElement('div');
|
|
tempDiv.innerHTML = trimmedHtml;
|
|
|
|
// Find the projectsListContainer in the parsed HTML
|
|
const responseContainer = tempDiv.querySelector('#projectsListContainer');
|
|
|
|
if (responseContainer) {
|
|
// Use the innerHTML directly
|
|
targetContainer.innerHTML = responseContainer.innerHTML;
|
|
} else {
|
|
// If the response IS the container (no wrapper), extract content
|
|
// Try regex to get content between opening and closing div tags
|
|
const match = trimmedHtml.match(/<div[^>]*id=["']projectsListContainer["'][^>]*>([\s\S]*?)<\/div>\s*$/);
|
|
if (match && match[1] !== undefined) {
|
|
targetContainer.innerHTML = match[1];
|
|
} else {
|
|
// If all else fails, try to find the first child element
|
|
const firstChild = tempDiv.firstElementChild;
|
|
if (firstChild && firstChild.id === 'projectsListContainer') {
|
|
targetContainer.innerHTML = firstChild.innerHTML;
|
|
} else {
|
|
// Last resort: replace entire container
|
|
targetContainer.outerHTML = trimmedHtml;
|
|
// Re-find the container after replacement
|
|
const newContainer = document.getElementById('projectsListContainer');
|
|
if (newContainer) targetContainer = newContainer;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Re-initialize any scripts that need to run after content update
|
|
if (window.setViewMode) {
|
|
const savedMode = localStorage.getItem('projectsViewMode') || 'list';
|
|
setViewMode(savedMode);
|
|
}
|
|
|
|
// Update filter chips after content update
|
|
this.updateFilters();
|
|
} else {
|
|
console.error('Could not find projectsListContainer or projectsContainer element');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error fetching filtered results:', error);
|
|
if (container) {
|
|
container.style.opacity = '';
|
|
container.style.pointerEvents = '';
|
|
}
|
|
const msg = 'Failed to filter results. Please try again.';
|
|
if (window.toastManager && typeof window.toastManager.error === 'function') {
|
|
window.toastManager.error(msg);
|
|
} else if (window.toastManager && typeof window.toastManager.show === 'function') {
|
|
window.toastManager.show({ message: msg, type: 'error' });
|
|
} else if (typeof window.showToast === 'function') {
|
|
window.showToast(msg, 'error');
|
|
}
|
|
})
|
|
.finally(() => {
|
|
// Remove loading indicator
|
|
if (container) {
|
|
container.style.opacity = '';
|
|
container.style.pointerEvents = '';
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// TOAST NOTIFICATIONS
|
|
// ============================================
|
|
class ToastManager {
|
|
constructor() {
|
|
this.container = null;
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.container = document.createElement('div');
|
|
this.container.className = 'toast-container';
|
|
document.body.appendChild(this.container);
|
|
}
|
|
|
|
show(message, type = 'info', duration = 5000) {
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast toast-${type}`;
|
|
|
|
const icons = {
|
|
success: 'fa-check',
|
|
error: 'fa-times',
|
|
warning: 'fa-exclamation',
|
|
info: 'fa-info'
|
|
};
|
|
|
|
toast.innerHTML = `
|
|
<div class="toast-icon">
|
|
<i class="fas ${icons[type]}"></i>
|
|
</div>
|
|
<div class="flex-1">
|
|
<p class="font-medium text-gray-900 dark:text-gray-100">${message}</p>
|
|
</div>
|
|
<button class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
`;
|
|
|
|
this.container.appendChild(toast);
|
|
|
|
// Close button
|
|
toast.querySelector('button').addEventListener('click', () => this.remove(toast));
|
|
|
|
// Auto remove
|
|
if (duration > 0) {
|
|
setTimeout(() => this.remove(toast), duration);
|
|
}
|
|
|
|
return toast;
|
|
}
|
|
|
|
remove(toast) {
|
|
toast.classList.add('removing');
|
|
setTimeout(() => toast.remove(), 300);
|
|
}
|
|
|
|
success(message, duration) {
|
|
return this.show(message, 'success', duration);
|
|
}
|
|
|
|
error(message, duration) {
|
|
return this.show(message, 'error', duration);
|
|
}
|
|
|
|
warning(message, duration) {
|
|
return this.show(message, 'warning', duration);
|
|
}
|
|
|
|
info(message, duration) {
|
|
return this.show(message, 'info', duration);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// UNDO/REDO FUNCTIONALITY
|
|
// ============================================
|
|
class UndoManager {
|
|
constructor() {
|
|
this.history = [];
|
|
this.currentIndex = -1;
|
|
}
|
|
|
|
addAction(action, undoFn, data) {
|
|
this.history = this.history.slice(0, this.currentIndex + 1);
|
|
this.history.push({ action, undoFn, data, timestamp: Date.now() });
|
|
this.currentIndex++;
|
|
|
|
this.showUndoBar(action);
|
|
}
|
|
|
|
undo() {
|
|
if (this.currentIndex < 0) return;
|
|
|
|
const item = this.history[this.currentIndex];
|
|
if (item.undoFn) {
|
|
item.undoFn(item.data);
|
|
}
|
|
this.currentIndex--;
|
|
|
|
window.toastManager?.success('Action undone');
|
|
}
|
|
|
|
showUndoBar(action) {
|
|
let bar = document.querySelector('.undo-bar');
|
|
if (!bar) {
|
|
bar = document.createElement('div');
|
|
bar.className = 'undo-bar';
|
|
bar.innerHTML = `
|
|
<span class="undo-message"></span>
|
|
<button class="px-3 py-1 bg-white/20 rounded hover:bg-white/30 transition-colors" onclick="undoManager.undo()">
|
|
Undo
|
|
</button>
|
|
`;
|
|
document.body.appendChild(bar);
|
|
}
|
|
|
|
bar.querySelector('.undo-message').textContent = action;
|
|
bar.classList.add('show');
|
|
|
|
setTimeout(() => {
|
|
bar.classList.remove('show');
|
|
}, 5000);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// FORM AUTO-SAVE
|
|
// ============================================
|
|
class FormAutoSave {
|
|
constructor(formElement, options = {}) {
|
|
this.form = formElement;
|
|
this.options = {
|
|
debounceMs: 1000,
|
|
storageKey: null,
|
|
onSave: null,
|
|
...options
|
|
};
|
|
this.debounceTimer = null;
|
|
this.indicator = null;
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
try {
|
|
// Validate form element
|
|
if (!this.form || !(this.form instanceof HTMLFormElement)) {
|
|
console.error('[FormAutoSave] Invalid form element provided');
|
|
return;
|
|
}
|
|
|
|
// Create indicator
|
|
this.indicator = document.createElement('div');
|
|
this.indicator.className = 'autosave-indicator';
|
|
this.indicator.innerHTML = `
|
|
<i class="fas fa-circle-notch fa-spin"></i>
|
|
<span class="autosave-text">Saving...</span>
|
|
`;
|
|
this.indicator.style.cssText = `
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
color: white;
|
|
padding: 12px 20px;
|
|
border-radius: 8px;
|
|
display: none;
|
|
align-items: center;
|
|
gap: 10px;
|
|
z-index: 10000;
|
|
font-size: 14px;
|
|
`;
|
|
document.body.appendChild(this.indicator);
|
|
|
|
// Load saved data
|
|
this.load();
|
|
|
|
// Monitor form changes with proper error handling
|
|
const scheduleSave = () => {
|
|
try {
|
|
this.scheduleAutoSave();
|
|
} catch (error) {
|
|
console.error('[FormAutoSave] Error scheduling save:', error);
|
|
}
|
|
};
|
|
|
|
this.form.addEventListener('input', scheduleSave, { passive: true });
|
|
this.form.addEventListener('change', scheduleSave, { passive: true });
|
|
|
|
// Clear saved data on successful submit
|
|
this.form.addEventListener('submit', () => {
|
|
try {
|
|
// Wait a bit to ensure submission is successful
|
|
setTimeout(() => {
|
|
if (this.options.storageKey) {
|
|
this.clear();
|
|
}
|
|
}, 1000);
|
|
} catch (error) {
|
|
console.error('[FormAutoSave] Error clearing on submit:', error);
|
|
}
|
|
}, { passive: true });
|
|
|
|
// Handle page unload - save current state
|
|
window.addEventListener('beforeunload', () => {
|
|
try {
|
|
if (this.debounceTimer) {
|
|
clearTimeout(this.debounceTimer);
|
|
this.save();
|
|
}
|
|
} catch (error) {
|
|
console.error('[FormAutoSave] Error saving on unload:', error);
|
|
}
|
|
}, { passive: true });
|
|
} catch (error) {
|
|
console.error('[FormAutoSave] Initialization error:', error);
|
|
}
|
|
}
|
|
|
|
scheduleAutoSave() {
|
|
clearTimeout(this.debounceTimer);
|
|
this.debounceTimer = setTimeout(() => this.save(), this.options.debounceMs);
|
|
}
|
|
|
|
save() {
|
|
try {
|
|
if (!this.form) {
|
|
console.warn('[FormAutoSave] Form element not available');
|
|
return;
|
|
}
|
|
|
|
this.showIndicator('saving');
|
|
|
|
const formData = new FormData(this.form);
|
|
const data = Object.fromEntries(formData.entries());
|
|
|
|
// Save to localStorage if storage key provided
|
|
if (this.options.storageKey) {
|
|
try {
|
|
localStorage.setItem(this.options.storageKey, JSON.stringify(data));
|
|
} catch (storageError) {
|
|
// Handle quota exceeded or other storage errors
|
|
if (storageError.name === 'QuotaExceededError') {
|
|
console.warn('[FormAutoSave] Storage quota exceeded, clearing old data');
|
|
// Try to clear and retry
|
|
try {
|
|
localStorage.removeItem(this.options.storageKey);
|
|
localStorage.setItem(this.options.storageKey, JSON.stringify(data));
|
|
} catch (retryError) {
|
|
console.error('[FormAutoSave] Failed to save after clearing:', retryError);
|
|
}
|
|
} else {
|
|
console.error('[FormAutoSave] Storage error:', storageError);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Call custom save handler if provided
|
|
if (this.options.onSave) {
|
|
try {
|
|
this.options.onSave(data, () => {
|
|
this.showIndicator('saved');
|
|
}, (error) => {
|
|
console.error('[FormAutoSave] Save handler error:', error);
|
|
this.showIndicator('error');
|
|
});
|
|
} catch (error) {
|
|
console.error('[FormAutoSave] Error in save handler:', error);
|
|
this.showIndicator('error');
|
|
}
|
|
} else {
|
|
this.showIndicator('saved');
|
|
}
|
|
} catch (error) {
|
|
console.error('[FormAutoSave] Save error:', error);
|
|
this.showIndicator('error');
|
|
}
|
|
}
|
|
|
|
load() {
|
|
if (!this.options.storageKey) return;
|
|
|
|
const saved = localStorage.getItem(this.options.storageKey);
|
|
if (!saved) return;
|
|
|
|
try {
|
|
const data = JSON.parse(saved);
|
|
Object.entries(data).forEach(([key, value]) => {
|
|
const input = this.form.querySelector(`[name="${key}"]`);
|
|
if (input) {
|
|
if (input.type === 'checkbox') {
|
|
input.checked = value === 'on';
|
|
} else {
|
|
input.value = value;
|
|
}
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.error('Failed to load saved form data:', e);
|
|
}
|
|
}
|
|
|
|
showIndicator(state) {
|
|
try {
|
|
if (!this.indicator) return;
|
|
|
|
this.indicator.className = 'autosave-indicator show ' + state;
|
|
const textElement = this.indicator.querySelector('.autosave-text');
|
|
if (textElement) {
|
|
const messages = {
|
|
'saving': 'Saving...',
|
|
'saved': 'Saved',
|
|
'error': 'Error saving'
|
|
};
|
|
textElement.textContent = messages[state] || 'Saving...';
|
|
}
|
|
|
|
// Update icon based on state
|
|
const iconElement = this.indicator.querySelector('i');
|
|
if (iconElement) {
|
|
if (state === 'saved') {
|
|
iconElement.className = 'fas fa-check';
|
|
iconElement.classList.remove('fa-spin');
|
|
} else if (state === 'error') {
|
|
iconElement.className = 'fas fa-exclamation-triangle';
|
|
iconElement.classList.remove('fa-spin');
|
|
} else {
|
|
iconElement.className = 'fas fa-circle-notch fa-spin';
|
|
}
|
|
}
|
|
|
|
// Show indicator
|
|
this.indicator.style.display = 'flex';
|
|
|
|
// Auto-hide after delay (longer for errors)
|
|
const delay = state === 'error' ? 5000 : 2000;
|
|
setTimeout(() => {
|
|
if (this.indicator) {
|
|
this.indicator.style.display = 'none';
|
|
this.indicator.classList.remove('show');
|
|
}
|
|
}, delay);
|
|
} catch (error) {
|
|
console.error('[FormAutoSave] Error showing indicator:', error);
|
|
}
|
|
}
|
|
|
|
clear() {
|
|
if (this.options.storageKey) {
|
|
localStorage.removeItem(this.options.storageKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// RECENTLY VIEWED TRACKER
|
|
// ============================================
|
|
class RecentlyViewedTracker {
|
|
constructor(maxItems = 10) {
|
|
this.maxItems = maxItems;
|
|
this.storageKey = 'recently_viewed';
|
|
}
|
|
|
|
track(item) {
|
|
let items = this.getItems();
|
|
|
|
// Remove if exists
|
|
items = items.filter(i => i.url !== item.url);
|
|
|
|
// Add to beginning
|
|
items.unshift({
|
|
...item,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
// Limit size
|
|
items = items.slice(0, this.maxItems);
|
|
|
|
localStorage.setItem(this.storageKey, JSON.stringify(items));
|
|
}
|
|
|
|
getItems() {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(this.storageKey) || '[]');
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
clear() {
|
|
localStorage.removeItem(this.storageKey);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// FAVORITES MANAGER
|
|
// ============================================
|
|
class FavoritesManager {
|
|
constructor() {
|
|
this.storageKey = 'favorites';
|
|
}
|
|
|
|
toggle(item) {
|
|
let favorites = this.getFavorites();
|
|
const index = favorites.findIndex(f => f.id === item.id && f.type === item.type);
|
|
|
|
if (index >= 0) {
|
|
favorites.splice(index, 1);
|
|
this.save(favorites);
|
|
return false;
|
|
} else {
|
|
favorites.push(item);
|
|
this.save(favorites);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
isFavorite(id, type) {
|
|
return this.getFavorites().some(f => f.id === id && f.type === type);
|
|
}
|
|
|
|
getFavorites() {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(this.storageKey) || '[]');
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
save(favorites) {
|
|
localStorage.setItem(this.storageKey, JSON.stringify(favorites));
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// DRAG & DROP
|
|
// ============================================
|
|
class DragDropManager {
|
|
constructor(containerElement, options = {}) {
|
|
this.container = containerElement;
|
|
this.options = {
|
|
onDrop: null,
|
|
onReorder: null,
|
|
...options
|
|
};
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
const items = this.container.querySelectorAll('[draggable="true"]');
|
|
|
|
items.forEach(item => {
|
|
item.addEventListener('dragstart', (e) => this.handleDragStart(e));
|
|
item.addEventListener('dragend', (e) => this.handleDragEnd(e));
|
|
item.addEventListener('dragover', (e) => this.handleDragOver(e));
|
|
item.addEventListener('drop', (e) => this.handleDrop(e));
|
|
});
|
|
}
|
|
|
|
handleDragStart(e) {
|
|
e.currentTarget.classList.add('dragging');
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
e.dataTransfer.setData('text/html', e.currentTarget.innerHTML);
|
|
}
|
|
|
|
handleDragEnd(e) {
|
|
e.currentTarget.classList.remove('dragging');
|
|
}
|
|
|
|
handleDragOver(e) {
|
|
if (e.preventDefault) {
|
|
e.preventDefault();
|
|
}
|
|
e.dataTransfer.dropEffect = 'move';
|
|
|
|
const dragging = this.container.querySelector('.dragging');
|
|
const afterElement = this.getDragAfterElement(e.clientY);
|
|
|
|
if (afterElement == null) {
|
|
this.container.appendChild(dragging);
|
|
} else {
|
|
this.container.insertBefore(dragging, afterElement);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
handleDrop(e) {
|
|
if (e.stopPropagation) {
|
|
e.stopPropagation();
|
|
}
|
|
|
|
if (this.options.onDrop) {
|
|
this.options.onDrop(e);
|
|
}
|
|
|
|
if (this.options.onReorder) {
|
|
const items = Array.from(this.container.querySelectorAll('[draggable="true"]'));
|
|
const order = items.map((item, index) => ({ element: item, index }));
|
|
this.options.onReorder(order);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
getDragAfterElement(y) {
|
|
const draggableElements = [...this.container.querySelectorAll('[draggable="true"]:not(.dragging)')];
|
|
|
|
return draggableElements.reduce((closest, child) => {
|
|
const box = child.getBoundingClientRect();
|
|
const offset = y - box.top - box.height / 2;
|
|
|
|
if (offset < 0 && offset > closest.offset) {
|
|
return { offset: offset, element: child };
|
|
} else {
|
|
return closest;
|
|
}
|
|
}, { offset: Number.NEGATIVE_INFINITY }).element;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// INITIALIZATION
|
|
// ============================================
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Initialize global managers
|
|
// Do not overwrite the modern toast system (toast-notifications.js).
|
|
// Only provide this legacy ToastManager if no modern manager is present.
|
|
if (!window.toastManager || typeof window.toastManager.dismiss !== 'function') {
|
|
window.toastManager = new ToastManager();
|
|
}
|
|
window.undoManager = new UndoManager();
|
|
window.recentlyViewed = new RecentlyViewedTracker();
|
|
window.favoritesManager = new FavoritesManager();
|
|
|
|
// Initialize enhanced tables
|
|
document.querySelectorAll('table[data-enhanced]').forEach(table => {
|
|
new EnhancedTable(table);
|
|
});
|
|
|
|
// Initialize live search
|
|
document.querySelectorAll('input[data-live-search]').forEach(input => {
|
|
new LiveSearch(input, {
|
|
onSearch: (query, callback) => {
|
|
// Custom search implementation
|
|
fetch(`/api/search?q=${encodeURIComponent(query)}`)
|
|
.then(r => r.json())
|
|
.then(callback)
|
|
.catch(console.error);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Initialize filter managers (skip forms that have custom handlers)
|
|
document.querySelectorAll('form[data-filter-form]').forEach(form => {
|
|
// Skip forms that have custom AJAX handlers
|
|
if (form.dataset.filterHandler === 'custom' ||
|
|
form.id === 'projectsFilterForm' ||
|
|
form.id === 'tasksFilterForm' ||
|
|
form.id === 'clientsFilterForm' ||
|
|
form.id === 'invoicesFilterForm' ||
|
|
form.id === 'quotesFilterForm' ||
|
|
form.id === 'timeEntriesFilterForm') {
|
|
return;
|
|
}
|
|
new FilterManager(form);
|
|
});
|
|
|
|
// Initialize auto-save forms
|
|
document.querySelectorAll('form[data-auto-save]').forEach(form => {
|
|
try {
|
|
// Skip if already initialized
|
|
if (form.dataset.autoSaveInitialized === 'true') {
|
|
return;
|
|
}
|
|
|
|
const storageKey = form.dataset.autoSaveKey || `autosave_${form.id || form.name || Date.now()}`;
|
|
const autoSave = new FormAutoSave(form, {
|
|
storageKey: storageKey,
|
|
debounceMs: parseInt(form.dataset.autoSaveDebounce || '1000'),
|
|
onSave: (data, callback, errorCallback) => {
|
|
// Custom save implementation
|
|
const formAction = form.action || window.location.pathname;
|
|
const formMethod = form.method || 'POST';
|
|
|
|
// Get CSRF token
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content ||
|
|
document.querySelector('input[name="csrf_token"]')?.value;
|
|
|
|
fetch(formAction, {
|
|
method: formMethod,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfToken || '',
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
},
|
|
body: JSON.stringify(data)
|
|
})
|
|
.then(response => {
|
|
if (response.ok) {
|
|
callback();
|
|
} else {
|
|
throw new Error(`Save failed: ${response.status} ${response.statusText}`);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('[FormAutoSave] Save request failed:', error);
|
|
if (errorCallback) {
|
|
errorCallback(error);
|
|
} else {
|
|
callback(); // Still show saved indicator even on error
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Mark as initialized
|
|
form.dataset.autoSaveInitialized = 'true';
|
|
|
|
// Store reference for potential cleanup
|
|
form._autoSaveInstance = autoSave;
|
|
} catch (error) {
|
|
console.error('[EnhancedUI] Error initializing form auto-save:', error);
|
|
}
|
|
});
|
|
|
|
// Count-up animations
|
|
document.querySelectorAll('[data-count-up]').forEach(el => {
|
|
const target = parseFloat(el.dataset.countUp);
|
|
const duration = parseInt(el.dataset.duration || '1000');
|
|
const decimals = parseInt(el.dataset.decimals || '0');
|
|
|
|
const observer = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
animateCount(el, 0, target, duration, decimals);
|
|
observer.unobserve(el);
|
|
}
|
|
});
|
|
});
|
|
|
|
observer.observe(el);
|
|
});
|
|
|
|
});
|
|
|
|
// ============================================
|
|
// UTILITY FUNCTIONS
|
|
// ============================================
|
|
function animateCount(element, start, end, duration, decimals = 0) {
|
|
const startTime = performance.now();
|
|
|
|
function update(currentTime) {
|
|
const elapsed = currentTime - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
|
|
const current = start + (end - start) * easeOutQuad(progress);
|
|
element.textContent = current.toFixed(decimals);
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(update);
|
|
}
|
|
}
|
|
|
|
requestAnimationFrame(update);
|
|
}
|
|
|
|
function easeOutQuad(t) {
|
|
return t * (2 - t);
|
|
}
|
|
|
|
/**
|
|
* Set submit button loading state (disabled, text, aria-busy).
|
|
* Use before/after async submit; pass loadingText to override "Saving...".
|
|
*/
|
|
function setSubmitButtonLoading(button, loading, loadingText) {
|
|
if (!button) return;
|
|
if (loading) {
|
|
button.dataset.originalSubmitText = button.textContent.trim();
|
|
button.textContent = loadingText || 'Saving...';
|
|
button.disabled = true;
|
|
button.setAttribute('aria-busy', 'true');
|
|
} else {
|
|
button.disabled = false;
|
|
button.removeAttribute('aria-busy');
|
|
if (button.dataset.originalSubmitText) {
|
|
button.textContent = button.dataset.originalSubmitText;
|
|
delete button.dataset.originalSubmitText;
|
|
}
|
|
}
|
|
}
|
|
if (typeof window !== 'undefined') {
|
|
window.setSubmitButtonLoading = setSubmitButtonLoading;
|
|
}
|
|
|
|
// Global functions for inline event handlers
|
|
async function bulkDelete() {
|
|
const confirmed = await showConfirm(
|
|
'Are you sure you want to delete the selected items?',
|
|
{
|
|
title: 'Delete Items',
|
|
confirmText: 'Delete',
|
|
cancelText: 'Cancel',
|
|
variant: 'danger'
|
|
}
|
|
);
|
|
if (confirmed) {
|
|
window.toastManager?.success('Items deleted successfully');
|
|
clearSelection();
|
|
}
|
|
}
|
|
|
|
function bulkExport() {
|
|
const table = document.querySelector('.enhanced-table');
|
|
if (table) {
|
|
const enhancedTable = table.__enhancedTable;
|
|
const data = enhancedTable?.getSelectedRowData() || [];
|
|
window.toastManager?.success('Export started');
|
|
}
|
|
}
|
|
|
|
function clearSelection() {
|
|
document.querySelectorAll('.row-checkbox:checked').forEach(cb => {
|
|
cb.checked = false;
|
|
cb.dispatchEvent(new Event('change'));
|
|
});
|
|
}
|
|
|