Update to v1.0.0-alpha6
- Add configurable filter views with AND/OR logic in settings - Implement column sorting and filtering via click menu popup - Add filter-as-you-type with 300ms debounce - Add tooltips to view buttons showing filter configuration - Fix server timeout issues (increased to 30s) - Maintain sort/filter state across table refreshes
This commit is contained in:
@@ -171,6 +171,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- View Management -->
|
||||||
|
<div class="bg-white rounded-lg shadow-2xl p-6 mb-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800 mb-4">Filter Views</h2>
|
||||||
|
<p class="text-gray-600 mb-4">Create custom filter buttons that appear on the main table page</p>
|
||||||
|
|
||||||
|
<div id="viewsList" class="space-y-3 mb-4">
|
||||||
|
<!-- Views will be dynamically generated here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="addViewBtn" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors text-sm font-medium">
|
||||||
|
+ Add New View
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="bg-white rounded-lg shadow-2xl p-6 flex justify-between items-center">
|
<div class="bg-white rounded-lg shadow-2xl p-6 flex justify-between items-center">
|
||||||
<button id="resetBtn" class="px-6 py-3 bg-gray-500 text-white font-semibold rounded-lg hover:bg-gray-600 transition-colors">
|
<button id="resetBtn" class="px-6 py-3 bg-gray-500 text-white font-semibold rounded-lg hover:bg-gray-600 transition-colors">
|
||||||
@@ -194,6 +208,7 @@
|
|||||||
const APP_NAME = 'Estimator Job List';
|
const APP_NAME = 'Estimator Job List';
|
||||||
let settingsRecordId = null;
|
let settingsRecordId = null;
|
||||||
let currentSettings = null;
|
let currentSettings = null;
|
||||||
|
let currentViews = [];
|
||||||
let autosaveTimer = null;
|
let autosaveTimer = null;
|
||||||
const AUTOSAVE_DELAY = 400;
|
const AUTOSAVE_DELAY = 400;
|
||||||
|
|
||||||
@@ -234,6 +249,21 @@
|
|||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Default views
|
||||||
|
const DEFAULT_VIEWS = [
|
||||||
|
{ id: 'all', label: 'All', filters: [] },
|
||||||
|
{ id: 'active', label: 'Active', filters: [{ field: 'Active', operator: 'equals', value: true }] },
|
||||||
|
{ id: 'inactive', label: 'Inactive', filters: [{ field: 'Active', operator: 'equals', value: false }] },
|
||||||
|
{ id: 'beth', label: 'Beth', filters: [{ field: 'Estimator', operator: 'equals', value: 'Beth' }] },
|
||||||
|
{ id: 'steve', label: 'Steve', filters: [{ field: 'Estimator', operator: 'equals', value: 'Steve' }] },
|
||||||
|
{ id: 'timothy', label: 'Timothy', filters: [{ field: 'Estimator', operator: 'equals', value: 'Timothy' }] },
|
||||||
|
{ id: 'anita', label: 'Anita', filters: [{ field: 'Estimator', operator: 'equals', value: 'Anita' }] },
|
||||||
|
{ id: 'daveJobs', label: 'Dave Jobs', filters: [{ field: 'Project_Manager', operator: 'equals', value: 'Dave Jobs' }] },
|
||||||
|
{ id: 'eddieJobs', label: 'Eddie Jobs', filters: [{ field: 'Project_Manager', operator: 'equals', value: 'Eddie Jobs' }] },
|
||||||
|
{ id: 'rickJobs', label: 'Rick Jobs', filters: [{ field: 'Project_Manager', operator: 'equals', value: 'Rick Jobs' }] },
|
||||||
|
{ id: 'noPM', label: 'No PM', filters: [{ field: 'Project_Manager', operator: 'empty', value: '' }] }
|
||||||
|
];
|
||||||
|
|
||||||
function syncColumnOrderNumbers() {
|
function syncColumnOrderNumbers() {
|
||||||
if (!currentSettings || !Array.isArray(currentSettings.columns)) return;
|
if (!currentSettings || !Array.isArray(currentSettings.columns)) return;
|
||||||
currentSettings.columns = currentSettings.columns.map((col, idx) => ({
|
currentSettings.columns = currentSettings.columns.map((col, idx) => ({
|
||||||
@@ -397,6 +427,11 @@
|
|||||||
|
|
||||||
syncColumnOrderNumbers();
|
syncColumnOrderNumbers();
|
||||||
renderSettings();
|
renderSettings();
|
||||||
|
|
||||||
|
// Load views
|
||||||
|
await loadViews();
|
||||||
|
renderViews();
|
||||||
|
|
||||||
document.getElementById('loadingIndicator').classList.add('hidden');
|
document.getElementById('loadingIndicator').classList.add('hidden');
|
||||||
document.getElementById('settingsContainer').classList.remove('hidden');
|
document.getElementById('settingsContainer').classList.remove('hidden');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -406,11 +441,274 @@
|
|||||||
currentSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
currentSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||||
syncColumnOrderNumbers();
|
syncColumnOrderNumbers();
|
||||||
renderSettings();
|
renderSettings();
|
||||||
|
|
||||||
|
// Load views with defaults
|
||||||
|
currentViews = JSON.parse(JSON.stringify(DEFAULT_VIEWS));
|
||||||
|
renderViews();
|
||||||
|
|
||||||
document.getElementById('loadingIndicator').classList.add('hidden');
|
document.getElementById('loadingIndicator').classList.add('hidden');
|
||||||
document.getElementById('settingsContainer').classList.remove('hidden');
|
document.getElementById('settingsContainer').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load views from PocketBase
|
||||||
|
async function loadViews() {
|
||||||
|
try {
|
||||||
|
if (!settingsRecordId) return;
|
||||||
|
|
||||||
|
const record = await pb.collection('app_preferences_settings').getOne(settingsRecordId);
|
||||||
|
|
||||||
|
if (record.app_views_json) {
|
||||||
|
const viewsData = typeof record.app_views_json === 'string'
|
||||||
|
? JSON.parse(record.app_views_json)
|
||||||
|
: record.app_views_json;
|
||||||
|
currentViews = viewsData || JSON.parse(JSON.stringify(DEFAULT_VIEWS));
|
||||||
|
} else {
|
||||||
|
currentViews = JSON.parse(JSON.stringify(DEFAULT_VIEWS));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Loaded views:', currentViews);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading views:', error);
|
||||||
|
currentViews = JSON.parse(JSON.stringify(DEFAULT_VIEWS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save views to PocketBase
|
||||||
|
let isSavingViews = false;
|
||||||
|
async function saveViews() {
|
||||||
|
if (isSavingViews) {
|
||||||
|
console.log('Views save already in progress, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSavingViews = true;
|
||||||
|
|
||||||
|
if (!settingsRecordId) {
|
||||||
|
console.error('No settings record ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a fresh PocketBase client to avoid auto-cancellation
|
||||||
|
const tempPb = new PocketBase('https://pocketbase.ccllc.pro');
|
||||||
|
|
||||||
|
// Copy auth from main client
|
||||||
|
if (pb.authStore.isValid) {
|
||||||
|
tempPb.authStore.save(pb.authStore.token, pb.authStore.model);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tempPb.collection('app_preferences_settings').update(settingsRecordId, {
|
||||||
|
app_views_json: JSON.stringify(currentViews)
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Views saved successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving views:', error);
|
||||||
|
// Don't throw - just log it
|
||||||
|
} finally {
|
||||||
|
isSavingViews = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render views list
|
||||||
|
function renderViews() {
|
||||||
|
const viewsList = document.getElementById('viewsList');
|
||||||
|
viewsList.innerHTML = '';
|
||||||
|
|
||||||
|
currentViews.forEach((view, index) => {
|
||||||
|
const viewDiv = document.createElement('div');
|
||||||
|
viewDiv.className = 'border border-gray-300 rounded-lg p-4 bg-gray-50';
|
||||||
|
|
||||||
|
let filtersHtml = '';
|
||||||
|
if (view.filters && view.filters.length > 0) {
|
||||||
|
filtersHtml = view.filters.map((filter, fIdx) => `
|
||||||
|
<div class="flex items-center space-x-2 mb-2">
|
||||||
|
<select class="px-2 py-1 border border-gray-300 rounded text-sm view-filter-field" data-view-idx="${index}" data-filter-idx="${fIdx}">
|
||||||
|
<option value="">-- Field --</option>
|
||||||
|
${COLUMNS.map(col => `<option value="${col}" ${filter.field === col ? 'selected' : ''}>${col.replace(/_/g, ' ')}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
<select class="px-2 py-1 border border-gray-300 rounded text-sm view-filter-operator" data-view-idx="${index}" data-filter-idx="${fIdx}">
|
||||||
|
<option value="equals" ${filter.operator === 'equals' ? 'selected' : ''}>Equals</option>
|
||||||
|
<option value="notEquals" ${filter.operator === 'notEquals' ? 'selected' : ''}>Not Equals</option>
|
||||||
|
<option value="contains" ${filter.operator === 'contains' ? 'selected' : ''}>Contains</option>
|
||||||
|
<option value="empty" ${filter.operator === 'empty' ? 'selected' : ''}>Is Empty</option>
|
||||||
|
<option value="notEmpty" ${filter.operator === 'notEmpty' ? 'selected' : ''}>Not Empty</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" placeholder="Value" class="px-2 py-1 border border-gray-300 rounded text-sm flex-1 view-filter-value"
|
||||||
|
data-view-idx="${index}" data-filter-idx="${fIdx}" value="${filter.value || ''}"
|
||||||
|
${filter.operator === 'empty' || filter.operator === 'notEmpty' ? 'disabled' : ''}>
|
||||||
|
<button class="px-2 py-1 bg-red-500 text-white rounded text-sm hover:bg-red-600 remove-filter-btn"
|
||||||
|
data-view-idx="${index}" data-filter-idx="${fIdx}">✕</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
viewDiv.innerHTML = `
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<input type="text" placeholder="View Label" class="px-3 py-1.5 border border-gray-300 rounded font-medium text-sm flex-1 mr-3 view-label"
|
||||||
|
data-view-idx="${index}" value="${view.label}">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button class="px-3 py-1 bg-gray-500 text-white rounded text-sm hover:bg-gray-600 move-view-up" data-view-idx="${index}" ${index === 0 ? 'disabled' : ''}>↑</button>
|
||||||
|
<button class="px-3 py-1 bg-gray-500 text-white rounded text-sm hover:bg-gray-600 move-view-down" data-view-idx="${index}" ${index === currentViews.length - 1 ? 'disabled' : ''}>↓</button>
|
||||||
|
<button class="px-3 py-1 bg-red-500 text-white rounded text-sm hover:bg-red-600 remove-view-btn" data-view-idx="${index}">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${view.filters && view.filters.length > 1 ? `
|
||||||
|
<div class="mb-2 flex items-center space-x-2">
|
||||||
|
<span class="text-sm text-gray-700 font-medium">Filter Logic:</span>
|
||||||
|
<select class="px-2 py-1 border border-gray-300 rounded text-sm view-filter-logic" data-view-idx="${index}">
|
||||||
|
<option value="AND" ${view.filterLogic !== 'OR' ? 'selected' : ''}>Match ALL (AND)</option>
|
||||||
|
<option value="OR" ${view.filterLogic === 'OR' ? 'selected' : ''}>Match ANY (OR)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
<div class="filters-container mb-2">
|
||||||
|
${filtersHtml}
|
||||||
|
</div>
|
||||||
|
<button class="px-3 py-1 bg-green-500 text-white rounded text-sm hover:bg-green-600 add-filter-btn" data-view-idx="${index}">+ Add Filter</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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
|
// Render settings UI
|
||||||
function renderSettings() {
|
function renderSettings() {
|
||||||
// Global settings
|
// Global settings
|
||||||
@@ -723,6 +1021,7 @@
|
|||||||
// Save settings
|
// Save settings
|
||||||
document.getElementById('saveBtn').addEventListener('click', async () => {
|
document.getElementById('saveBtn').addEventListener('click', async () => {
|
||||||
await saveSettings(false);
|
await saveSettings(false);
|
||||||
|
await saveViews();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset to defaults
|
// Reset to defaults
|
||||||
@@ -731,6 +1030,17 @@
|
|||||||
reinitializeToDefaults();
|
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
|
// Cancel/Close buttons
|
||||||
document.getElementById('cancelBtn').addEventListener('click', () => {
|
document.getElementById('cancelBtn').addEventListener('click', () => {
|
||||||
|
|||||||
+390
-6
@@ -242,6 +242,13 @@
|
|||||||
<p class="text-gray-600 mt-1">Scroll horizontally and vertically • First two columns are frozen</p>
|
<p class="text-gray-600 mt-1">Scroll horizontally and vertically • First two columns are frozen</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- View Buttons Section -->
|
||||||
|
<div class="bg-white rounded-lg shadow-2xl px-4 py-2 mt-2">
|
||||||
|
<div id="viewButtonsContainer" class="flex items-center space-x-2 flex-wrap gap-y-2">
|
||||||
|
<!-- View buttons will be dynamically generated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Search Section -->
|
<!-- Search Section -->
|
||||||
<div class="bg-white rounded-lg shadow-2xl px-4 py-2 mt-2">
|
<div class="bg-white rounded-lg shadow-2xl px-4 py-2 mt-2">
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
@@ -326,7 +333,7 @@
|
|||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="mt-2 flex justify-between items-center text-white text-xs px-4">
|
<div class="mt-2 flex justify-between items-center text-white text-xs px-4">
|
||||||
<span id="footerEmail"></span>
|
<span id="footerEmail"></span>
|
||||||
<span>v1.0.0-alpha5</span>
|
<span>v1.0.0-alpha6</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -371,6 +378,11 @@
|
|||||||
let appSettings = null;
|
let appSettings = null;
|
||||||
let appSettingsRecordId = null;
|
let appSettingsRecordId = null;
|
||||||
let appSettingsUnsubscribe = 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 tableReloadTimer = null;
|
||||||
let isLoadingTableData = false;
|
let isLoadingTableData = false;
|
||||||
let allRecords = []; // Store all records for filtering
|
let allRecords = []; // Store all records for filtering
|
||||||
@@ -415,6 +427,44 @@
|
|||||||
}, 200);
|
}, 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)
|
// Load app settings from PocketBase (global settings)
|
||||||
async function loadAppSettings() {
|
async function loadAppSettings() {
|
||||||
try {
|
try {
|
||||||
@@ -507,8 +557,133 @@
|
|||||||
const colSettings = getColumnSettings(col.name);
|
const colSettings = getColumnSettings(col.name);
|
||||||
const alignment = colSettings?.alignment || 'left';
|
const alignment = colSettings?.alignment || 'left';
|
||||||
const wrapClass = colSettings?.headerWrap ? 'whitespace-normal break-words' : 'whitespace-nowrap';
|
const wrapClass = colSettings?.headerWrap ? 'whitespace-normal break-words' : 'whitespace-nowrap';
|
||||||
return `<th class="px-4 py-3 text-${alignment} font-semibold ${wrapClass}">${prettyLabel(col.name)}</th>`;
|
const sortIcon = currentSort.column === col.name
|
||||||
|
? (currentSort.direction === 'asc' ? ' ▲' : ' ▼')
|
||||||
|
: '';
|
||||||
|
const hasFilter = columnFilters[col.name] ? ' 🔍' : '';
|
||||||
|
|
||||||
|
return `<th class="px-4 py-3 text-${alignment} font-semibold ${wrapClass} cursor-pointer hover:bg-gray-100" data-column="${col.name}">${prettyLabel(col.name)}${sortIcon}${hasFilter}</th>`;
|
||||||
}).join('');
|
}).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 = `
|
||||||
|
<div class="text-sm font-semibold mb-2 pb-2 border-b border-gray-200">${prettyLabel(columnName)}</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<button class="w-full text-left px-3 py-2 hover:bg-blue-100 rounded text-sm" data-action="sort-asc">
|
||||||
|
${isSorted && currentSort.direction === 'asc' ? '✓ ' : ''}Sort A → Z
|
||||||
|
</button>
|
||||||
|
<button class="w-full text-left px-3 py-2 hover:bg-blue-100 rounded text-sm" data-action="sort-desc">
|
||||||
|
${isSorted && currentSort.direction === 'desc' ? '✓ ' : ''}Sort Z → A
|
||||||
|
</button>
|
||||||
|
${isSorted ? '<button class="w-full text-left px-3 py-2 hover:bg-red-100 rounded text-sm text-red-600" data-action="clear-sort">Clear Sort</button>' : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 pt-3">
|
||||||
|
<label class="block text-xs text-gray-600 mb-1">Filter:</label>
|
||||||
|
<input type="text"
|
||||||
|
class="w-full px-2 py-1 border border-gray-300 rounded text-sm focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Search in ${prettyLabel(columnName)}..."
|
||||||
|
value="${currentFilter}"
|
||||||
|
id="columnFilterInput"
|
||||||
|
/>
|
||||||
|
${currentFilter ? '<div class="mt-2"><button class="w-full px-3 py-1 bg-red-500 text-white rounded text-xs hover:bg-red-600" data-action="clear-filter">Clear Filter</button></div>' : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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)
|
// Apply global settings to table (styles)
|
||||||
@@ -702,6 +877,9 @@
|
|||||||
await loadAppSettings();
|
await loadAppSettings();
|
||||||
console.log('Settings loaded');
|
console.log('Settings loaded');
|
||||||
|
|
||||||
|
await loadAppViews();
|
||||||
|
console.log('Views loaded');
|
||||||
|
|
||||||
await startSettingsWatch();
|
await startSettingsWatch();
|
||||||
console.log('Settings watch started');
|
console.log('Settings watch started');
|
||||||
|
|
||||||
@@ -736,6 +914,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
await loadAppSettings();
|
await loadAppSettings();
|
||||||
|
await loadAppViews();
|
||||||
await startSettingsWatch();
|
await startSettingsWatch();
|
||||||
showTable();
|
showTable();
|
||||||
await loadTableData();
|
await loadTableData();
|
||||||
@@ -775,6 +954,164 @@
|
|||||||
|
|
||||||
// Setup search listener after table is shown
|
// Setup search listener after table is shown
|
||||||
setupSearchListener();
|
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
|
// Settings button handler
|
||||||
@@ -1078,8 +1415,34 @@
|
|||||||
tableBody.innerHTML = '';
|
tableBody.innerHTML = '';
|
||||||
const visibleColumns = getVisibleColumns();
|
const visibleColumns = getVisibleColumns();
|
||||||
|
|
||||||
// Filter records based on search term
|
// Filter records based on search term and column filters
|
||||||
const filteredRecords = filterRecords(allRecords, currentSearchTerm);
|
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
|
// Update search result count
|
||||||
const searchResultCount = document.getElementById('searchResultCount');
|
const searchResultCount = document.getElementById('searchResultCount');
|
||||||
@@ -1178,8 +1541,29 @@
|
|||||||
|
|
||||||
// Filter records based on search term
|
// Filter records based on search term
|
||||||
function filterRecords(records, searchTerm) {
|
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()) {
|
if (!searchTerm || !searchTerm.trim()) {
|
||||||
return records;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
const term = searchTerm.toLowerCase().trim();
|
const term = searchTerm.toLowerCase().trim();
|
||||||
@@ -1197,7 +1581,7 @@
|
|||||||
'note_thread_text'
|
'note_thread_text'
|
||||||
];
|
];
|
||||||
|
|
||||||
return records.filter(record => {
|
return filtered.filter(record => {
|
||||||
return searchFields.some(field => {
|
return searchFields.some(field => {
|
||||||
const value = record[field];
|
const value = record[field];
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
|
|||||||
Reference in New Issue
Block a user