Compare commits
5 Commits
71e5070e6c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ca7d2e5c2 | |||
| ae306e946b | |||
| ad4a4fdf4b | |||
| 5a34170454 | |||
| 2365bb9b4d |
@@ -1,20 +0,0 @@
|
||||
# Azure AD / Microsoft Authentication
|
||||
CLIENT_ID=your-client-id
|
||||
CLIENT_SECRET=your-client-secret
|
||||
TENANT_ID=your-tenant-id
|
||||
REDIRECT_URI=https://your-domain.com/auth/callback
|
||||
|
||||
# PocketBase Configuration
|
||||
PB_DB=https://pocketbase.ccllc.pro
|
||||
PB_COLLECTION=Job_Info_TestEnv
|
||||
PB_AUTH_COLLECTION=Users
|
||||
|
||||
# Redis/Valkey Configuration (Caching & Search)
|
||||
# Connect to the same Redis/Valkey instance as Job-Info-Prod
|
||||
REDIS_URL=redis://localhost:6379
|
||||
# For production, use the same connection string as Job-Info-Prod
|
||||
# Example: REDIS_URL=redis://redis-server:6379
|
||||
# Example with auth: REDIS_URL=redis://:password@redis-server:6379
|
||||
|
||||
# Optional
|
||||
PORT=3025
|
||||
+462
-2
@@ -82,6 +82,7 @@
|
||||
<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>
|
||||
@@ -91,11 +92,71 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Striped Rows</label>
|
||||
<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>
|
||||
@@ -110,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">
|
||||
@@ -133,6 +208,7 @@
|
||||
const APP_NAME = 'Estimator Job List';
|
||||
let settingsRecordId = null;
|
||||
let currentSettings = null;
|
||||
let currentViews = [];
|
||||
let autosaveTimer = null;
|
||||
const AUTOSAVE_DELAY = 400;
|
||||
|
||||
@@ -173,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) => ({
|
||||
@@ -215,6 +306,13 @@
|
||||
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) {
|
||||
@@ -250,6 +348,8 @@
|
||||
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);
|
||||
@@ -327,6 +427,11 @@
|
||||
|
||||
syncColumnOrderNumbers();
|
||||
renderSettings();
|
||||
|
||||
// Load views
|
||||
await loadViews();
|
||||
renderViews();
|
||||
|
||||
document.getElementById('loadingIndicator').classList.add('hidden');
|
||||
document.getElementById('settingsContainer').classList.remove('hidden');
|
||||
} catch (error) {
|
||||
@@ -336,17 +441,288 @@
|
||||
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');
|
||||
@@ -386,10 +762,70 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="flex items-center space-x-2 mt-6">
|
||||
<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>
|
||||
|
||||
@@ -585,6 +1021,7 @@
|
||||
// Save settings
|
||||
document.getElementById('saveBtn').addEventListener('click', async () => {
|
||||
await saveSettings(false);
|
||||
await saveViews();
|
||||
});
|
||||
|
||||
// Reset to defaults
|
||||
@@ -593,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', () => {
|
||||
@@ -602,6 +1050,18 @@
|
||||
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) {
|
||||
|
||||
+1413
-87
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,76 @@ app.get('/health', (c) => {
|
||||
return c.json({ ok: true, pbDB: process.env.PB_DB });
|
||||
});
|
||||
|
||||
// Email endpoint - fetch unread emails from sales@cardoza.construction
|
||||
app.get('/api/emails/unread', async (c) => {
|
||||
try {
|
||||
const pbToken = c.req.header('X-PB-Token');
|
||||
|
||||
if (!pbToken) {
|
||||
return c.json({ success: false, message: 'Missing pbToken' }, 401);
|
||||
}
|
||||
|
||||
// Validate user token
|
||||
setUserPocketBaseAuth(pbToken);
|
||||
try {
|
||||
await pb.collection(process.env.PB_AUTH_COLLECTION || 'Users').authRefresh();
|
||||
} catch (e) {
|
||||
console.error('auth_validation_failed', { message: (e as Error)?.message });
|
||||
return c.json({ success: false, message: 'Invalid token' }, 401);
|
||||
}
|
||||
|
||||
// Get Graph API token
|
||||
const graphToken = await getGraphToken();
|
||||
|
||||
// Fetch unread emails from sales@cardoza.construction mailbox
|
||||
const mailboxEmail = 'sales@cardoza.construction';
|
||||
const graphUrl = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(mailboxEmail)}/messages?$filter=isRead eq false&$select=id,subject,from,receivedDateTime,bodyPreview,webLink&$orderby=receivedDateTime desc&$top=50`;
|
||||
|
||||
const response = await fetch(graphUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${graphToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('graph_api_error', { status: response.status, error: errorText });
|
||||
|
||||
// Provide helpful error message
|
||||
if (response.status === 403) {
|
||||
return c.json({
|
||||
success: false,
|
||||
message: 'Permission denied. The application needs Mail.Read permission in Azure AD to access the sales mailbox.',
|
||||
hint: 'Ask your Azure AD administrator to grant "Mail.Read" application permission to this app.',
|
||||
error: errorText
|
||||
}, 403);
|
||||
}
|
||||
|
||||
return c.json({ success: false, message: 'Failed to fetch emails', error: errorText }, response.status);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const emails = data.value || [];
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
emails: emails.map((email: any) => ({
|
||||
id: email.id,
|
||||
subject: email.subject || '(No Subject)',
|
||||
from: email.from?.emailAddress?.name || email.from?.emailAddress?.address || 'Unknown',
|
||||
fromEmail: email.from?.emailAddress?.address || '',
|
||||
receivedDateTime: email.receivedDateTime,
|
||||
bodyPreview: email.bodyPreview || '',
|
||||
webLink: email.webLink || ''
|
||||
}))
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('email_fetch_error', { message: (err as Error)?.message, stack: (err as Error)?.stack });
|
||||
return c.json({ success: false, message: 'Internal error: ' + (err as Error)?.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Submit endpoint
|
||||
app.post('/api/submit', async (c) => {
|
||||
try {
|
||||
@@ -113,12 +183,12 @@ app.post('/api/admin/login', async (c) => {
|
||||
return c.json({ message: 'Missing email or password' }, 400);
|
||||
}
|
||||
|
||||
// Authenticate as PocketBase superuser via `_superusers` collection
|
||||
const authData = await pb.collection('_superusers').authWithPassword(email, password);
|
||||
// Authenticate as regular user
|
||||
const authData = await pb.collection('users').authWithPassword(email, password);
|
||||
if (!authData || !authData.token) {
|
||||
return c.json({ message: 'Invalid credentials' }, 401);
|
||||
}
|
||||
return c.json({ ok: true, token: authData.token, email: authData.record?.email, role: 'superuser' });
|
||||
return c.json({ ok: true, token: authData.token, email: authData.record?.email });
|
||||
} catch (err) {
|
||||
console.error('admin_login_error', { message: (err as Error)?.message });
|
||||
return c.json({ message: 'Login failed' }, 401);
|
||||
@@ -653,4 +723,5 @@ export default {
|
||||
port: PORT,
|
||||
hostname: '0.0.0.0',
|
||||
fetch: app.fetch,
|
||||
idleTimeout: 30,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user