Files
Job-List/appsettings.html
T
aewing ae306e946b 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
2026-01-02 05:39:11 +00:00

1078 lines
48 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Estimator Job List - Settings</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#4c51bf',
secondary: '#5b21b6',
}
}
}
}
</script>
<style>
body {
overflow-y: auto;
}
.settings-container {
max-width: 1400px;
}
.color-preview {
width: 30px;
height: 30px;
border: 2px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.choice-item {
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 12px;
margin-bottom: 8px;
background: #f9fafb;
}
.section-header {
font-weight: 600;
color: #374151;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid #e5e7eb;
}
</style>
</head>
<body class="min-h-screen bg-gradient-to-br from-primary to-secondary p-6 font-sans">
<!-- Header -->
<div class="bg-white rounded-lg shadow-2xl p-6 mb-6 settings-container mx-auto">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-gray-800">Estimator Job List Settings</h1>
<p class="text-gray-600 mt-1">Configure table appearance and behavior</p>
</div>
<button id="closeBtn" class="text-gray-500 hover:text-gray-700 text-3xl font-bold">&times;</button>
</div>
</div>
<!-- Loading Indicator -->
<div id="loadingIndicator" class="text-center text-white">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
<p class="mt-4">Loading settings...</p>
</div>
<!-- Settings Container -->
<div id="settingsContainer" class="hidden settings-container mx-auto">
<!-- Global Settings -->
<div class="bg-white rounded-lg shadow-2xl p-6 mb-6">
<h2 class="text-2xl font-bold text-gray-800 mb-4">Global Settings</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Row Height (px)</label>
<input type="number" id="rowHeight" min="30" max="100"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent">
<p class="text-xs text-gray-500 mt-1">Fixed height for all data rows</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Font Size (px)</label>
<input type="number" id="fontSize" min="10" max="24"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Row Options</label>
<label class="flex items-center space-x-2 mt-2">
<input type="checkbox" id="stripedRows" class="w-5 h-5 text-primary rounded">
<span class="text-sm text-gray-700">Enable alternating row colors</span>
</label>
<label class="flex items-center space-x-2 mt-2">
<input type="checkbox" id="fixedRowHeight" class="w-5 h-5 text-primary rounded">
<span class="text-sm text-gray-700">Fixed row height (no flex)</span>
</label>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Data Cell Vertical Alignment</label>
<select id="cellVerticalAlign"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent">
<option value="top">Top</option>
<option value="middle">Middle</option>
<option value="bottom">Bottom</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Header Vertical Alignment</label>
<select id="headerVerticalAlign"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent">
<option value="top">Top</option>
<option value="middle">Middle</option>
<option value="bottom">Bottom</option>
</select>
</div>
</div>
<div class="mt-6">
<h3 class="text-lg font-semibold text-gray-800 mb-3">Border Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Border Color</label>
<div class="flex items-center space-x-2">
<input type="color" id="borderColor"
class="w-16 h-10 rounded cursor-pointer border border-gray-300">
<input type="text" id="borderColorText"
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary text-sm"
placeholder="#e5e7eb">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Border Width (px)</label>
<input type="number" id="borderWidth" min="0" max="5"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Border Options</label>
<label class="flex items-center space-x-2 mt-2">
<input type="checkbox" id="showRowBorders" class="w-5 h-5 text-primary rounded">
<span class="text-sm text-gray-700">Show row borders</span>
</label>
<label class="flex items-center space-x-2 mt-2">
<input type="checkbox" id="showColumnBorders" class="w-5 h-5 text-primary rounded">
<span class="text-sm text-gray-700">Show column borders</span>
</label>
</div>
</div>
</div>
</div>
<!-- Column Settings -->
<div class="bg-white rounded-lg shadow-2xl p-6 mb-6">
<h2 class="text-2xl font-bold text-gray-800 mb-4">Column Settings</h2>
<p class="text-gray-600 mb-4">Configure individual column properties, widths, and dropdown choices</p>
<div id="columnsList" class="space-y-4">
<!-- Column settings will be dynamically generated here -->
</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">
Reset to Defaults
</button>
<div class="space-x-4">
<button id="cancelBtn" class="px-6 py-3 bg-gray-300 text-gray-800 font-semibold rounded-lg hover:bg-gray-400 transition-colors">
Cancel
</button>
<button id="saveBtn" class="px-6 py-3 bg-gradient-to-br from-primary to-secondary text-white font-semibold rounded-lg hover:opacity-95 transition-opacity shadow-lg">
Save Settings
</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/pocketbase/dist/pocketbase.umd.js"></script>
<script>
const pb = new PocketBase('https://pocketbase.ccllc.pro');
const APP_NAME = 'Estimator Job List';
let settingsRecordId = null;
let currentSettings = null;
let currentViews = [];
let autosaveTimer = null;
const AUTOSAVE_DELAY = 400;
// Column names from schema
const COLUMNS = [
'Job_Number', 'Job_Full_Name', 'Active', 'Added_To_Calendar', 'Attachment_List',
'Company_Client', 'Contact_Person', 'Docs_Uploaded', 'Due_Date', 'Due_Date_Counter',
'Due_Date_Source', 'Due_Time', 'Email', 'Estimate_Approved', 'Estimate_Draft',
'Estimate_Reviewed', 'Estimator', 'Flag', 'Has_Attachments', 'Job_Address',
'Job_Codes', 'Job_Division', 'Job_Folder_Link', 'Job_Name', 'Job_Number_Parent',
'Job_QB_Link', 'Job_Status', 'Job_Type', 'Note_Ref', 'Notes', 'Office_Rep',
'PSwift_Uploaded', 'Phone_Number', 'Project_Manager', 'QB_Created',
'Schedule_Confidence', 'Start_Date', 'Submission_Date', 'Tax_Exempt',
'Tax_Exempt_Docs_Uploaded', 'Voxer_Created', 'Voxer_Link', 'last_note_at',
'latest_note_summary', 'note_count', 'note_thread_text'
];
// Default settings
const DEFAULT_SETTINGS = {
rowHeight: 40,
fontSize: 14,
stripedRows: true,
columns: COLUMNS.map((col, idx) => ({
name: col,
order: idx + 1,
readOnly: col === 'Active' || col === 'Due_Date_Counter' || col === 'Job_Number',
width: col === 'Job_Number' ? 120 : col === 'Job_Full_Name' ? 250 : 150,
visible: true,
choices: col === 'Job_Status' ? [
{ value: 'Estimating', bgColor: '#fef3c7', bgColorAlt: '#fde68a', fontStyle: 'normal' },
{ value: 'Est Sent', bgColor: '#dbeafe', bgColorAlt: '#bfdbfe', fontStyle: 'normal' },
{ value: 'Est Hold', bgColor: '#fee2e2', bgColorAlt: '#fecaca', fontStyle: 'normal' },
{ value: 'Follow Up', bgColor: '#e0e7ff', bgColorAlt: '#c7d2fe', fontStyle: 'normal' },
{ value: 'Awarded', bgColor: '#dcfce7', bgColorAlt: '#bbf7d0', fontStyle: 'bold' },
{ value: 'In Progress', bgColor: '#d1fae5', bgColorAlt: '#a7f3d0', fontStyle: 'normal' },
{ value: 'Billed In Progress', bgColor: '#cffafe', bgColorAlt: '#a5f3fc', fontStyle: 'normal' }
] : []
}))
};
// 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) => ({
...col,
order: idx + 1
}));
}
async function reinitializeToDefaults() {
try {
// Create fresh settings from defaults
currentSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
console.log('Reinitialized to defaults, saving...');
// Immediately save to PocketBase (not queued)
const jsonString = JSON.stringify(currentSettings);
await pb.collection('app_preferences_settings').update(settingsRecordId, {
app_json_prefs: jsonString,
app_txt_prefs: `Reset to defaults on ${new Date().toISOString()}`
});
console.log('Settings reset to defaults and saved');
renderSettings();
alert('Settings reset to defaults successfully!');
} catch (error) {
console.error('Error reinitializing settings:', error);
alert('Error resetting to defaults: ' + error.message);
}
}
function syncColumnOrderNumbers() {
if (!currentSettings || !Array.isArray(currentSettings.columns)) return;
currentSettings.columns = currentSettings.columns.map((col, idx) => ({
...col,
order: idx + 1
}));
}
function captureGlobalInputs() {
currentSettings.rowHeight = parseInt(document.getElementById('rowHeight').value);
currentSettings.fontSize = parseInt(document.getElementById('fontSize').value);
currentSettings.stripedRows = document.getElementById('stripedRows').checked;
currentSettings.fixedRowHeight = document.getElementById('fixedRowHeight').checked;
currentSettings.cellVerticalAlign = document.getElementById('cellVerticalAlign').value;
currentSettings.headerVerticalAlign = document.getElementById('headerVerticalAlign').value;
currentSettings.borderColor = document.getElementById('borderColor').value;
currentSettings.borderWidth = parseInt(document.getElementById('borderWidth').value);
currentSettings.showRowBorders = document.getElementById('showRowBorders').checked;
currentSettings.showColumnBorders = document.getElementById('showColumnBorders').checked;
}
async function saveSettings(isAuto = false) {
try {
captureGlobalInputs();
syncColumnOrderNumbers();
console.log('Saving columns with orders:', currentSettings.columns.map(c => ({ name: c.name, order: c.order, visible: c.visible })).slice(0, 5));
const jsonString = JSON.stringify(currentSettings);
// Authenticate as principal agent for settings updates
const agentAuth = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: await getEnvVar('PB_AGENT_EMAIL'),
password: await getEnvVar('PB_AGENT_PASSWORD')
})
}).then(r => r.json());
if (!agentAuth.ok || !agentAuth.token) {
throw new Error('Failed to authenticate as principal agent');
}
// Use agent token to update settings
const tempPb = new PocketBase('https://pocketbase.ccllc.pro');
tempPb.authStore.save(agentAuth.token, null);
await tempPb.collection('app_preferences_settings').update(settingsRecordId, {
app_json_prefs: jsonString,
app_txt_prefs: `Updated on ${new Date().toISOString()}`
});
console.log('Settings saved to PocketBase successfully');
if (!isAuto) {
alert('Settings saved successfully!');
// Redirect back to estimator table
window.location.href = '/estimatortable.html';
}
} catch (error) {
console.error('Error saving settings:', error);
if (!isAuto) {
alert('Error saving settings: ' + error.message + '. Please try again.');
}
}
}
// Helper to get env vars from server
async function getEnvVar(key) {
// Since we can't access env directly from browser, we'll need server endpoint
// For now, hardcode getting them via backend
const response = await fetch('/api/admin/env?key=' + key);
if (!response.ok) throw new Error('Failed to get env var');
const data = await response.json();
return data.value;
}
function queueAutosave() {
if (autosaveTimer) {
clearTimeout(autosaveTimer);
}
autosaveTimer = setTimeout(() => saveSettings(true), AUTOSAVE_DELAY);
}
// Load settings from PocketBase
async function loadSettings() {
try {
document.getElementById('loadingIndicator').classList.remove('hidden');
console.log('Loading settings for app:', APP_NAME);
// Try to find existing settings record
const records = await pb.collection('app_preferences_settings').getFullList({
filter: `app_name = "${APP_NAME}"`
});
console.log('Found records:', records.length);
if (records.length > 0) {
settingsRecordId = records[0].id;
console.log('Settings record ID:', settingsRecordId);
console.log('Raw app_json_prefs:', records[0].app_json_prefs);
// Try to parse the JSON
try {
if (records[0].app_json_prefs && typeof records[0].app_json_prefs === 'string') {
currentSettings = JSON.parse(records[0].app_json_prefs);
console.log('Parsed settings successfully');
} else if (records[0].app_json_prefs && typeof records[0].app_json_prefs === 'object') {
// Already an object, no need to parse
currentSettings = records[0].app_json_prefs;
console.log('Settings already an object');
} else {
console.log('No settings found, using defaults');
currentSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
}
} catch (parseError) {
console.error('Error parsing settings JSON:', parseError);
currentSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
}
} else {
console.log('No existing record, creating new one');
// Create new record with defaults
const newRecord = await pb.collection('app_preferences_settings').create({
app_name: APP_NAME,
app_json_prefs: JSON.stringify(DEFAULT_SETTINGS),
app_txt_prefs: 'Default settings'
});
settingsRecordId = newRecord.id;
currentSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
console.log('Created new record:', settingsRecordId);
}
syncColumnOrderNumbers();
renderSettings();
// Load views
await loadViews();
renderViews();
document.getElementById('loadingIndicator').classList.add('hidden');
document.getElementById('settingsContainer').classList.remove('hidden');
} catch (error) {
console.error('Error loading settings:', error);
console.error('Error details:', error.message, error.stack);
alert('Error loading settings: ' + error.message + '. Using defaults.');
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
document.getElementById('rowHeight').value = currentSettings.rowHeight || 40;
document.getElementById('fontSize').value = currentSettings.fontSize || 14;
document.getElementById('stripedRows').checked = currentSettings.stripedRows !== false;
document.getElementById('fixedRowHeight').checked = currentSettings.fixedRowHeight !== false;
document.getElementById('cellVerticalAlign').value = currentSettings.cellVerticalAlign || 'middle';
document.getElementById('headerVerticalAlign').value = currentSettings.headerVerticalAlign || 'middle';
document.getElementById('borderColor').value = currentSettings.borderColor || '#e5e7eb';
document.getElementById('borderColorText').value = currentSettings.borderColor || '#e5e7eb';
document.getElementById('borderWidth').value = currentSettings.borderWidth || 1;
document.getElementById('showRowBorders').checked = currentSettings.showRowBorders !== false;
document.getElementById('showColumnBorders').checked = currentSettings.showColumnBorders !== false;
// Column settings
const columnsList = document.getElementById('columnsList');
columnsList.innerHTML = '';
currentSettings.columns.forEach((col, index) => {
const colDiv = document.createElement('div');
colDiv.className = 'border border-gray-200 rounded-lg p-4 bg-gray-50';
colDiv.innerHTML = `
<div class="flex justify-between items-center mb-3">
<h3 class="text-lg font-semibold text-gray-800">${col.name}</h3>
<div class="flex items-center space-x-3">
<div class="flex flex-col text-xs text-gray-500">
<button class="hover:text-primary" data-col-index="${index}" data-action="move-up" title="Move up">▲</button>
<button class="hover:text-primary" data-col-index="${index}" data-action="move-down" title="Move down">▼</button>
</div>
<label class="flex items-center space-x-2">
<input type="checkbox" ${col.visible ? 'checked' : ''} data-col-index="${index}" data-field="visible" class="w-4 h-4">
<span class="text-sm text-gray-700">Visible</span>
</label>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Column Width (px)</label>
<input type="number" value="${col.width}" min="50" max="500"
data-col-index="${index}" data-field="width"
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-primary text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Order</label>
<input type="number" value="${col.order ?? index + 1}" min="1" max="${currentSettings.columns.length}"
data-col-index="${index}" data-action="set-order"
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-primary text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Text Alignment</label>
<select data-col-index="${index}" data-field="alignment"
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-primary text-sm">
<option value="left" ${(col.alignment || 'left') === 'left' ? 'selected' : ''}>Left</option>
<option value="center" ${col.alignment === 'center' ? 'selected' : ''}>Center</option>
<option value="right" ${col.alignment === 'right' ? 'selected' : ''}>Right</option>
</select>
</div>
<div class="flex flex-col justify-center space-y-1">
<label class="flex items-center space-x-2">
<input type="checkbox" ${col.readOnly ? 'checked' : ''} data-col-index="${index}" data-field="readOnly" class="w-4 h-4">
<span class="text-sm text-gray-700">Read-only</span>
</label>
<label class="flex items-center space-x-2">
<input type="checkbox" ${col.headerWrap ? 'checked' : ''} data-col-index="${index}" data-field="headerWrap" class="w-4 h-4">
<span class="text-sm text-gray-700">Wrap header</span>
</label>
<label class="flex items-center space-x-2">
<input type="checkbox" ${col.showFullTextOnHover ? 'checked' : ''} data-col-index="${index}" data-field="showFullTextOnHover" class="w-4 h-4">
<span class="text-sm text-gray-700">Hover tooltip</span>
</label>
</div>
</div>
<div class="mt-4">
<div class="flex justify-between items-center mb-2">
<label class="block text-sm font-medium text-gray-700">Boolean Value Formatting (for checkboxes)</label>
</div>
<div class="grid grid-cols-2 gap-4 mb-3">
<div class="border border-gray-200 rounded p-3">
<label class="text-xs font-medium text-gray-600 block mb-2">True Value Style</label>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-gray-600 block mb-1">Background</label>
<input type="color" value="${col.trueBgColor || '#d1fae5'}"
data-col-index="${index}" data-field="trueBgColor"
class="w-full h-8 rounded cursor-pointer">
</div>
<div>
<label class="text-xs text-gray-600 block mb-1">Alt Background</label>
<input type="color" value="${col.trueBgColorAlt || '#a7f3d0'}"
data-col-index="${index}" data-field="trueBgColorAlt"
class="w-full h-8 rounded cursor-pointer">
</div>
</div>
</div>
<div class="border border-gray-200 rounded p-3">
<label class="text-xs font-medium text-gray-600 block mb-2">False Value Style</label>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-gray-600 block mb-1">Background</label>
<input type="color" value="${col.falseBgColor || '#fee2e2'}"
data-col-index="${index}" data-field="falseBgColor"
class="w-full h-8 rounded cursor-pointer">
</div>
<div>
<label class="text-xs text-gray-600 block mb-1">Alt Background</label>
<input type="color" value="${col.falseBgColorAlt || '#fecaca'}"
data-col-index="${index}" data-field="falseBgColorAlt"
class="w-full h-8 rounded cursor-pointer">
</div>
</div>
</div>
</div>
</div>
<div class="mt-4">
<div class="flex justify-between items-center mb-2">
<label class="block text-sm font-medium text-gray-700">Dropdown Choices</label>
<button class="text-sm text-primary hover:text-secondary font-medium" data-col-index="${index}" data-action="add-choice">
+ Add Choice
</button>
</div>
<div id="choices-${index}" class="space-y-2">
${renderChoices(col.choices || [], index)}
</div>
</div>
`;
columnsList.appendChild(colDiv);
});
attachEventListeners();
}
// Render choices for a column
function renderChoices(choices, colIndex) {
if (!choices || choices.length === 0) {
return '<p class="text-sm text-gray-500 italic">No choices defined</p>';
}
return choices.map((choice, choiceIndex) => `
<div class="choice-item">
<div class="grid grid-cols-12 gap-3 items-center">
<div class="col-span-3">
<input type="text" value="${choice.value}"
data-col-index="${colIndex}" data-choice-index="${choiceIndex}" data-field="choice-value"
class="w-full px-2 py-1 border border-gray-300 rounded text-sm" placeholder="Value">
</div>
<div class="col-span-2">
<label class="text-xs text-gray-600 block mb-1">Main Color</label>
<div class="flex items-center space-x-2">
<input type="color" value="${choice.bgColor}"
data-col-index="${colIndex}" data-choice-index="${choiceIndex}" data-field="choice-bgColor"
class="w-8 h-8 rounded cursor-pointer">
<span class="text-xs text-gray-500">${choice.bgColor}</span>
</div>
</div>
<div class="col-span-2">
<label class="text-xs text-gray-600 block mb-1">Alt Color</label>
<div class="flex items-center space-x-2">
<input type="color" value="${choice.bgColorAlt}"
data-col-index="${colIndex}" data-choice-index="${choiceIndex}" data-field="choice-bgColorAlt"
class="w-8 h-8 rounded cursor-pointer">
<span class="text-xs text-gray-500">${choice.bgColorAlt}</span>
</div>
</div>
<div class="col-span-2">
<label class="text-xs text-gray-600 block mb-1">Font Style</label>
<select data-col-index="${colIndex}" data-choice-index="${choiceIndex}" data-field="choice-fontStyle"
class="w-full px-2 py-1 border border-gray-300 rounded text-sm">
<option value="normal" ${choice.fontStyle === 'normal' ? 'selected' : ''}>Normal</option>
<option value="bold" ${choice.fontStyle === 'bold' ? 'selected' : ''}>Bold</option>
<option value="italic" ${choice.fontStyle === 'italic' ? 'selected' : ''}>Italic</option>
</select>
</div>
<div class="col-span-2">
<div class="flex items-center space-x-2 h-full">
<div class="color-preview" style="background-color: ${choice.bgColor}; font-style: ${choice.fontStyle}; font-weight: ${choice.fontStyle === 'bold' ? 'bold' : 'normal'};">
<span class="text-xs flex items-center justify-center h-full">A</span>
</div>
<div class="color-preview" style="background-color: ${choice.bgColorAlt}; font-style: ${choice.fontStyle}; font-weight: ${choice.fontStyle === 'bold' ? 'bold' : 'normal'};">
<span class="text-xs flex items-center justify-center h-full">A</span>
</div>
</div>
</div>
<div class="col-span-1 text-right">
<button class="text-red-500 hover:text-red-700 font-bold text-lg"
data-col-index="${colIndex}" data-choice-index="${choiceIndex}" data-action="remove-choice">
&times;
</button>
</div>
</div>
</div>
`).join('');
}
// Attach event listeners
function attachEventListeners() {
// Column field changes
document.querySelectorAll('[data-col-index][data-field]').forEach(el => {
const handler = (e) => {
const colIndex = parseInt(e.target.dataset.colIndex);
const field = e.target.dataset.field;
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
console.log('Field changed:', field, 'value:', value, 'colIndex:', colIndex);
if (field === 'choice-value' || field === 'choice-bgColor' || field === 'choice-bgColorAlt' || field === 'choice-fontStyle') {
const choiceIndex = parseInt(e.target.dataset.choiceIndex);
const choiceField = field.replace('choice-', '');
if (!currentSettings.columns[colIndex].choices) {
currentSettings.columns[colIndex].choices = [];
}
currentSettings.columns[colIndex].choices[choiceIndex][choiceField] = value;
console.log('Updated choice:', currentSettings.columns[colIndex].choices[choiceIndex]);
} else {
currentSettings.columns[colIndex][field] = field === 'width' ? parseInt(value) : value;
console.log('Updated column field:', field, currentSettings.columns[colIndex][field]);
if (field === 'visible' || field === 'readOnly' || field === 'width') {
queueAutosave();
}
}
};
el.removeEventListener('change', handler);
el.removeEventListener('input', handler);
el.addEventListener('change', handler);
el.addEventListener('input', handler);
});
// Add/Remove choice buttons and column move / set-order
document.querySelectorAll('[data-action]').forEach(el => {
const handler = (e) => {
const action = e.target.dataset.action;
const colIndex = parseInt(e.target.dataset.colIndex);
console.log('Action:', action, 'colIndex:', colIndex);
if (action === 'set-order') {
const desired = Math.max(1, Math.min(currentSettings.columns.length, parseInt(e.target.value || '0', 10)));
const targetIndex = desired - 1;
if (targetIndex === colIndex) return;
const [col] = currentSettings.columns.splice(colIndex, 1);
currentSettings.columns.splice(targetIndex, 0, col);
console.log('Moved column', col.name, 'from index', colIndex, 'to', targetIndex);
syncColumnOrderNumbers();
console.log('Column orders after sync:', currentSettings.columns.map(c => ({ name: c.name, order: c.order })).slice(0, 5));
renderSettings();
queueAutosave();
} else if (action === 'move-up') {
if (colIndex > 0) {
const tmp = currentSettings.columns[colIndex - 1];
currentSettings.columns[colIndex - 1] = currentSettings.columns[colIndex];
currentSettings.columns[colIndex] = tmp;
syncColumnOrderNumbers();
renderSettings();
queueAutosave();
}
} else if (action === 'move-down') {
if (colIndex < currentSettings.columns.length - 1) {
const tmp = currentSettings.columns[colIndex + 1];
currentSettings.columns[colIndex + 1] = currentSettings.columns[colIndex];
currentSettings.columns[colIndex] = tmp;
syncColumnOrderNumbers();
renderSettings();
queueAutosave();
}
} else if (action === 'add-choice') {
if (!currentSettings.columns[colIndex].choices) {
currentSettings.columns[colIndex].choices = [];
}
currentSettings.columns[colIndex].choices.push({
value: 'New Choice',
bgColor: '#ffffff',
bgColorAlt: '#f3f4f6',
fontStyle: 'normal'
});
console.log('Added choice, total:', currentSettings.columns[colIndex].choices.length);
renderSettings();
queueAutosave();
} else if (action === 'remove-choice') {
const choiceIndex = parseInt(e.target.dataset.choiceIndex);
currentSettings.columns[colIndex].choices.splice(choiceIndex, 1);
console.log('Removed choice at index:', choiceIndex);
renderSettings();
queueAutosave();
}
};
// Use click for buttons; change (not input) for numeric order to avoid immediate rerender while typing
el.removeEventListener('click', handler);
el.removeEventListener('change', handler);
if (el.tagName === 'INPUT' && el.type === 'number' && el.dataset.action === 'set-order') {
el.addEventListener('change', handler);
} else {
el.addEventListener('click', handler);
}
});
}
// Save settings
document.getElementById('saveBtn').addEventListener('click', async () => {
await saveSettings(false);
await saveViews();
});
// Reset to defaults
document.getElementById('resetBtn').addEventListener('click', () => {
if (confirm('Are you sure you want to reset all settings to defaults? This will reset all columns to their original order.')) {
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', () => {
window.location.href = '/estimatortable.html';
});
document.getElementById('closeBtn').addEventListener('click', () => {
window.location.href = '/estimatortable.html';
});
// Sync border color picker with text input
document.getElementById('borderColor').addEventListener('input', (e) => {
document.getElementById('borderColorText').value = e.target.value;
});
document.getElementById('borderColorText').addEventListener('input', (e) => {
const value = e.target.value;
if (/^#[0-9A-F]{6}$/i.test(value)) {
document.getElementById('borderColor').value = value;
}
});
// Initialize
if (pb.authStore.isValid) {
console.log('User is authenticated:', pb.authStore.model?.email);
loadSettings();
} else {
console.log('User not authenticated');
alert('Please login first');
window.location.href = '/estimatortable.html';
}
</script>
</body>
</html>