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:
2026-01-02 05:39:11 +00:00
parent ad4a4fdf4b
commit ae306e946b
3 changed files with 701 additions and 6 deletions
+310
View File
@@ -171,6 +171,20 @@
</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 -->
<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">
@@ -194,6 +208,7 @@
const APP_NAME = 'Estimator Job List';
let settingsRecordId = null;
let currentSettings = null;
let currentViews = [];
let autosaveTimer = null;
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() {
if (!currentSettings || !Array.isArray(currentSettings.columns)) return;
currentSettings.columns = currentSettings.columns.map((col, idx) => ({
@@ -397,6 +427,11 @@
syncColumnOrderNumbers();
renderSettings();
// Load views
await loadViews();
renderViews();
document.getElementById('loadingIndicator').classList.add('hidden');
document.getElementById('settingsContainer').classList.remove('hidden');
} catch (error) {
@@ -406,11 +441,274 @@
currentSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
syncColumnOrderNumbers();
renderSettings();
// Load views with defaults
currentViews = JSON.parse(JSON.stringify(DEFAULT_VIEWS));
renderViews();
document.getElementById('loadingIndicator').classList.add('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
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', () => {