diff --git a/appsettings.html b/appsettings.html
index bc4a44c..bcccbf4 100644
--- a/appsettings.html
+++ b/appsettings.html
@@ -171,6 +171,20 @@
+
+
@@ -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,
};