diff --git a/appsettings.html b/appsettings.html index bc4a44c..bcccbf4 100644 --- a/appsettings.html +++ b/appsettings.html @@ -171,6 +171,20 @@ + +
+

Filter Views

+

Create custom filter buttons that appear on the main table page

+ +
+ +
+ + +
+
+
+ `).join(''); + } + + viewDiv.innerHTML = ` +
+ +
+ + + +
+
+ ${view.filters && view.filters.length > 1 ? ` +
+ Filter Logic: + +
+ ` : ''} +
+ ${filtersHtml} +
+ + `; + + viewsList.appendChild(viewDiv); + }); + + attachViewEventListeners(); + } + + // Attach event listeners for view management + function attachViewEventListeners() { + // View label changes + document.querySelectorAll('.view-label').forEach(input => { + input.addEventListener('input', (e) => { + const idx = parseInt(e.target.dataset.viewIdx); + currentViews[idx].label = e.target.value; + scheduleViewSave(); + }); + }); + + // Filter logic changes (AND/OR) + document.querySelectorAll('.view-filter-logic').forEach(select => { + select.addEventListener('change', (e) => { + const viewIdx = parseInt(e.target.dataset.viewIdx); + currentViews[viewIdx].filterLogic = e.target.value; + scheduleViewSave(); + }); + }); + + // Filter field changes + document.querySelectorAll('.view-filter-field').forEach(select => { + select.addEventListener('change', (e) => { + const viewIdx = parseInt(e.target.dataset.viewIdx); + const filterIdx = parseInt(e.target.dataset.filterIdx); + currentViews[viewIdx].filters[filterIdx].field = e.target.value; + scheduleViewSave(); + }); + }); + + // Filter operator changes + document.querySelectorAll('.view-filter-operator').forEach(select => { + select.addEventListener('change', (e) => { + const viewIdx = parseInt(e.target.dataset.viewIdx); + const filterIdx = parseInt(e.target.dataset.filterIdx); + const operator = e.target.value; + currentViews[viewIdx].filters[filterIdx].operator = operator; + + // Disable value input for empty/notEmpty operators + const valueInput = document.querySelector(`.view-filter-value[data-view-idx="${viewIdx}"][data-filter-idx="${filterIdx}"]`); + if (valueInput) { + valueInput.disabled = operator === 'empty' || operator === 'notEmpty'; + if (valueInput.disabled) { + valueInput.value = ''; + currentViews[viewIdx].filters[filterIdx].value = ''; + } + } + + scheduleViewSave(); + }); + }); + + // Filter value changes + document.querySelectorAll('.view-filter-value').forEach(input => { + input.addEventListener('input', (e) => { + const viewIdx = parseInt(e.target.dataset.viewIdx); + const filterIdx = parseInt(e.target.dataset.filterIdx); + currentViews[viewIdx].filters[filterIdx].value = e.target.value; + scheduleViewSave(); + }); + }); + + // Add filter button + document.querySelectorAll('.add-filter-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const viewIdx = parseInt(e.target.dataset.viewIdx); + currentViews[viewIdx].filters.push({ field: '', operator: 'equals', value: '' }); + renderViews(); + scheduleViewSave(); + }); + }); + + // Remove filter button + document.querySelectorAll('.remove-filter-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const viewIdx = parseInt(e.target.dataset.viewIdx); + const filterIdx = parseInt(e.target.dataset.filterIdx); + currentViews[viewIdx].filters.splice(filterIdx, 1); + renderViews(); + scheduleViewSave(); + }); + }); + + // Remove view button + document.querySelectorAll('.remove-view-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const viewIdx = parseInt(e.target.dataset.viewIdx); + if (confirm(`Delete view "${currentViews[viewIdx].label}"?`)) { + currentViews.splice(viewIdx, 1); + renderViews(); + scheduleViewSave(); + } + }); + }); + + // Move view up + document.querySelectorAll('.move-view-up').forEach(btn => { + btn.addEventListener('click', (e) => { + const idx = parseInt(e.target.dataset.viewIdx); + if (idx > 0) { + [currentViews[idx - 1], currentViews[idx]] = [currentViews[idx], currentViews[idx - 1]]; + renderViews(); + scheduleViewSave(); + } + }); + }); + + // Move view down + document.querySelectorAll('.move-view-down').forEach(btn => { + btn.addEventListener('click', (e) => { + const idx = parseInt(e.target.dataset.viewIdx); + if (idx < currentViews.length - 1) { + [currentViews[idx], currentViews[idx + 1]] = [currentViews[idx + 1], currentViews[idx]]; + renderViews(); + scheduleViewSave(); + } + }); + }); + } + + // Schedule view save with debouncing + let viewSaveTimer = null; + function scheduleViewSave() { + if (viewSaveTimer) clearTimeout(viewSaveTimer); + viewSaveTimer = setTimeout(async () => { + try { + await saveViews(); + console.log('Views auto-saved'); + } catch (error) { + console.error('Auto-save views failed:', error); + } + }, AUTOSAVE_DELAY); + } + // Render settings UI function renderSettings() { // Global settings @@ -723,6 +1021,7 @@ // Save settings document.getElementById('saveBtn').addEventListener('click', async () => { await saveSettings(false); + await saveViews(); }); // Reset to defaults @@ -731,6 +1030,17 @@ reinitializeToDefaults(); } }); + + // Add new view button + document.getElementById('addViewBtn').addEventListener('click', () => { + currentViews.push({ + id: 'view_' + Date.now(), + label: 'New View', + filters: [] + }); + renderViews(); + scheduleViewSave(); + }); // Cancel/Close buttons document.getElementById('cancelBtn').addEventListener('click', () => { diff --git a/estimatortable.html b/estimatortable.html index e818e1b..ad61c66 100644 --- a/estimatortable.html +++ b/estimatortable.html @@ -242,6 +242,13 @@

Scroll horizontally and vertically • First two columns are frozen

+ +
+
+ +
+
+
@@ -326,7 +333,7 @@
- v1.0.0-alpha5 + v1.0.0-alpha6
@@ -371,6 +378,11 @@ let appSettings = null; let appSettingsRecordId = null; let appSettingsUnsubscribe = null; + let appViews = null; + let appViewsRecordId = null; + let currentViewFilter = null; // Current active view filter + let currentSort = { column: 'Job_Number', direction: 'desc' }; // Current sort state + let columnFilters = {}; // Column-specific filters { columnName: searchTerm } let tableReloadTimer = null; let isLoadingTableData = false; let allRecords = []; // Store all records for filtering @@ -415,6 +427,44 @@ }, 200); } + // Load app views from PocketBase + async function loadAppViews() { + try { + const records = await pb.collection('app_preferences_settings').getFullList({ + filter: `app_name = "Estimator Job List"` + }); + + if (records.length > 0) { + const viewsData = records[0].app_views_json; + appViewsRecordId = records[0].id; + if (viewsData) { + appViews = typeof viewsData === 'string' ? JSON.parse(viewsData) : viewsData; + console.log('Loaded app views:', appViews); + } + } + } catch (error) { + console.error('Error loading app views:', error); + } + } + + // Save app views to PocketBase + async function saveAppViews() { + try { + if (!appViewsRecordId) { + console.error('No views record ID available'); + return; + } + + const viewsJson = JSON.stringify(appViews); + await pb.collection('app_preferences_settings').update(appViewsRecordId, { + app_views_json: viewsJson + }); + console.log('Views saved successfully'); + } catch (error) { + console.error('Error saving views:', error); + } + } + // Load app settings from PocketBase (global settings) async function loadAppSettings() { try { @@ -507,8 +557,133 @@ const colSettings = getColumnSettings(col.name); const alignment = colSettings?.alignment || 'left'; const wrapClass = colSettings?.headerWrap ? 'whitespace-normal break-words' : 'whitespace-nowrap'; - return `${prettyLabel(col.name)}`; + const sortIcon = currentSort.column === col.name + ? (currentSort.direction === 'asc' ? ' ▲' : ' ▼') + : ''; + const hasFilter = columnFilters[col.name] ? ' 🔍' : ''; + + return `${prettyLabel(col.name)}${sortIcon}${hasFilter}`; }).join(''); + + // Attach click handlers for column menu + document.querySelectorAll('th[data-column]').forEach(th => { + th.addEventListener('click', (e) => { + showColumnMenu(e.currentTarget, e.currentTarget.dataset.column); + }); + }); + } + + // Show column menu popup + function showColumnMenu(headerElement, columnName) { + // Remove any existing menu + const existingMenu = document.getElementById('columnMenu'); + if (existingMenu) existingMenu.remove(); + + const menu = document.createElement('div'); + menu.id = 'columnMenu'; + menu.className = 'absolute bg-white border border-gray-300 rounded-lg shadow-lg p-3 z-[2000]'; + menu.style.minWidth = '250px'; + + const rect = headerElement.getBoundingClientRect(); + menu.style.left = `${rect.left}px`; + menu.style.top = `${rect.bottom + 5}px`; + + const currentFilter = columnFilters[columnName] || ''; + const isSorted = currentSort.column === columnName; + + menu.innerHTML = ` +
${prettyLabel(columnName)}
+ +
+ + + ${isSorted ? '' : ''} +
+ +
+ + + ${currentFilter ? '
' : ''} +
+ `; + + document.body.appendChild(menu); + + // Focus the filter input + const filterInput = menu.querySelector('#columnFilterInput'); + if (filterInput) filterInput.focus(); + + // Debounce filter application to avoid re-rendering while typing + let filterTimeout; + filterInput.addEventListener('input', (e) => { + clearTimeout(filterTimeout); + filterTimeout = setTimeout(() => { + const value = e.target.value.trim(); + if (value) { + columnFilters[columnName] = value; + } else { + delete columnFilters[columnName]; + } + // Only re-render table rows, not the header (which would destroy the menu) + renderTableRows(); + }, 300); // Wait 300ms after user stops typing + }); + + // Handle menu actions + menu.addEventListener('click', (e) => { + const action = e.target.dataset.action; + if (!action) return; + + switch (action) { + case 'sort-asc': + currentSort.column = columnName; + currentSort.direction = 'asc'; + renderTableHeader(); + renderTableRows(); + menu.remove(); + break; + case 'sort-desc': + currentSort.column = columnName; + currentSort.direction = 'desc'; + renderTableHeader(); + renderTableRows(); + menu.remove(); + break; + case 'clear-sort': + currentSort.column = 'Job_Number'; + currentSort.direction = 'desc'; + renderTableHeader(); + renderTableRows(); + menu.remove(); + break; + case 'clear-filter': + delete columnFilters[columnName]; + renderTableHeader(); + renderTableRows(); + menu.remove(); + break; + } + }); + + // Close menu when clicking outside + const closeOnOutsideClick = (e) => { + if (!menu.contains(e.target) && e.target !== headerElement) { + menu.remove(); + // Update header to show filter icon after menu closes + renderTableHeader(); + document.removeEventListener('click', closeOnOutsideClick); + } + }; + setTimeout(() => document.addEventListener('click', closeOnOutsideClick), 0); } // Apply global settings to table (styles) @@ -702,6 +877,9 @@ await loadAppSettings(); console.log('Settings loaded'); + await loadAppViews(); + console.log('Views loaded'); + await startSettingsWatch(); console.log('Settings watch started'); @@ -736,6 +914,7 @@ } await loadAppSettings(); + await loadAppViews(); await startSettingsWatch(); showTable(); await loadTableData(); @@ -775,6 +954,164 @@ // Setup search listener after table is shown setupSearchListener(); + + // Setup view button listeners + setupViewButtons(); + } + + // Set up view button event handlers + function setupViewButtons() { + if (!appViews || appViews.length === 0) { + console.log('No views loaded'); + return; + } + + const container = document.getElementById('viewButtonsContainer'); + container.innerHTML = ''; + + // Generate buttons dynamically from appViews + appViews.forEach((view, idx) => { + const button = document.createElement('button'); + button.id = `view_${view.id}`; + button.className = idx === 0 + ? 'px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm font-medium transition-colors' + : 'px-3 py-1.5 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 text-sm font-medium transition-colors'; + button.textContent = view.label; + + // Generate tooltip showing filter configuration + if (view.filters && view.filters.length > 0) { + const filterDescriptions = view.filters.map(filter => { + const fieldName = filter.field.replace(/_/g, ' '); + let desc = fieldName; + + switch (filter.operator) { + case 'equals': + desc += ` = ${filter.value}`; + break; + case 'notEquals': + desc += ` ≠ ${filter.value}`; + break; + case 'contains': + desc += ` contains "${filter.value}"`; + break; + case 'empty': + desc += ` is empty`; + break; + case 'notEmpty': + desc += ` is not empty`; + break; + } + return desc; + }); + + const logic = view.filterLogic || 'AND'; + const tooltip = filterDescriptions.join(`\n${logic} `); + button.title = tooltip; + } else { + button.title = 'Show all records (no filters)'; + } + + button.addEventListener('click', () => { + // Update active button styling + container.querySelectorAll('button').forEach(btn => { + btn.className = 'px-3 py-1.5 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 text-sm font-medium transition-colors'; + }); + button.className = 'px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm font-medium transition-colors'; + + // Create filter function from view definition + if (!view.filters || view.filters.length === 0) { + currentViewFilter = null; + } else { + currentViewFilter = (record) => { + const filterLogic = view.filterLogic || 'AND'; + const evaluateFilter = (filter) => { + const fieldValue = record[filter.field]; + + switch (filter.operator) { + case 'equals': + // Handle boolean comparison + if (filter.value === 'true' || filter.value === true) { + return fieldValue === true; + } else if (filter.value === 'false' || filter.value === false) { + return fieldValue === false; + } + return String(fieldValue) === String(filter.value); + case 'notEquals': + if (filter.value === 'true' || filter.value === true) { + return fieldValue !== true; + } else if (filter.value === 'false' || filter.value === false) { + return fieldValue !== false; + } + return String(fieldValue) !== String(filter.value); + case 'contains': + return fieldValue && String(fieldValue).toLowerCase().includes(String(filter.value).toLowerCase()); + case 'empty': + return !fieldValue || String(fieldValue).trim() === ''; + case 'notEmpty': + return fieldValue && String(fieldValue).trim() !== ''; + default: + return true; + } + }; + + // Apply AND or OR logic + if (filterLogic === 'OR') { + return view.filters.some(filter => evaluateFilter(filter)); + } else { + return view.filters.every(filter => evaluateFilter(filter)); + } + }; + } + + renderTableRows(); + }); + + container.appendChild(button); + }); + + // Set first view as active filter on initial load + if (appViews.length > 0 && appViews[0].filters && appViews[0].filters.length > 0) { + const firstView = appViews[0]; + const filterLogic = firstView.filterLogic || 'AND'; + + currentViewFilter = (record) => { + const evaluateFilter = (filter) => { + const fieldValue = record[filter.field]; + + switch (filter.operator) { + case 'equals': + if (filter.value === 'true' || filter.value === true) { + return fieldValue === true; + } else if (filter.value === 'false' || filter.value === false) { + return fieldValue === false; + } + return String(fieldValue) === String(filter.value); + case 'notEquals': + if (filter.value === 'true' || filter.value === true) { + return fieldValue !== true; + } else if (filter.value === 'false' || filter.value === false) { + return fieldValue !== false; + } + return String(fieldValue) !== String(filter.value); + case 'contains': + return fieldValue && String(fieldValue).toLowerCase().includes(String(filter.value).toLowerCase()); + case 'empty': + return !fieldValue || String(fieldValue).trim() === ''; + case 'notEmpty': + return fieldValue && String(fieldValue).trim() !== ''; + default: + return true; + } + }; + + // Apply AND or OR logic + if (filterLogic === 'OR') { + return firstView.filters.some(filter => evaluateFilter(filter)); + } else { + return firstView.filters.every(filter => evaluateFilter(filter)); + } + }; + } } // Settings button handler @@ -1078,8 +1415,34 @@ tableBody.innerHTML = ''; const visibleColumns = getVisibleColumns(); - // Filter records based on search term - const filteredRecords = filterRecords(allRecords, currentSearchTerm); + // Filter records based on search term and column filters + let filteredRecords = filterRecords(allRecords, currentSearchTerm); + + // Apply sorting + if (currentSort.column) { + filteredRecords = filteredRecords.slice().sort((a, b) => { + let valA = a[currentSort.column]; + let valB = b[currentSort.column]; + + // Handle null/undefined values + if (valA === null || valA === undefined) valA = ''; + if (valB === null || valB === undefined) valB = ''; + + // Handle numeric values + if (!isNaN(valA) && !isNaN(valB)) { + valA = parseFloat(valA) || 0; + valB = parseFloat(valB) || 0; + } else { + // Convert to string for comparison + valA = String(valA).toLowerCase(); + valB = String(valB).toLowerCase(); + } + + if (valA < valB) return currentSort.direction === 'asc' ? -1 : 1; + if (valA > valB) return currentSort.direction === 'asc' ? 1 : -1; + return 0; + }); + } // Update search result count const searchResultCount = document.getElementById('searchResultCount'); @@ -1178,8 +1541,29 @@ // Filter records based on search term function filterRecords(records, searchTerm) { + let filtered = records; + + // Apply view filter first + if (currentViewFilter) { + filtered = filtered.filter(record => { + return currentViewFilter(record); + }); + } + + // Apply column-specific filters + if (Object.keys(columnFilters).length > 0) { + filtered = filtered.filter(record => { + return Object.entries(columnFilters).every(([column, filterTerm]) => { + const value = record[column]; + if (!value && value !== false && value !== 0) return false; + return String(value).toLowerCase().includes(filterTerm.toLowerCase()); + }); + }); + } + + // Then apply global search term filter if (!searchTerm || !searchTerm.trim()) { - return records; + return filtered; } const term = searchTerm.toLowerCase().trim(); @@ -1197,7 +1581,7 @@ 'note_thread_text' ]; - return records.filter(record => { + return filtered.filter(record => { return searchFields.some(field => { const value = record[field]; if (!value) return false; diff --git a/server.ts b/server.ts index 6d6ba01..188ba95 100644 --- a/server.ts +++ b/server.ts @@ -653,4 +653,5 @@ export default { port: PORT, hostname: '0.0.0.0', fetch: app.fetch, + idleTimeout: 30, };