feat: add estimator table with frozen columns, settings UI, and dynamic column configuration
- Created estimatortable.html with 47 columns displaying Job_Info_TestEnv data - Implemented frozen first two columns (Job_Number, Job_Full_Name) with sticky positioning - Implemented frozen header row with proper z-index layering - Added proper scrolling (vertical and horizontal within table container) - Applied .cursorrules formatting: dates in Central Time (MM/dd/yyyy), booleans as checkboxes - Implemented calculated fields: Due_Date_Counter with business logic, Active field read-only - Made boolean fields editable with PocketBase updates (except Active) - Created appsettings.html for comprehensive table configuration - Added global settings: row height, font size, striped rows - Added per-column settings: width, visibility, read-only status - Added dropdown choice configuration with color pickers and font styles for Job_Status - Settings save/load from app_preferences_settings collection - Fixed column position mapping with COLUMN_ORDER array for correct visibility and width application - Fixed horizontal scroll by calculating total table width dynamically - Added server routes for /estimatortable.html and /appsettings.html - Updated port to 6500 with improved console output
This commit is contained in:
@@ -0,0 +1,482 @@
|
||||
<!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">×</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">
|
||||
</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">Striped Rows</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>
|
||||
</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>
|
||||
|
||||
<!-- 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;
|
||||
|
||||
// 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 => ({
|
||||
name: col,
|
||||
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' }
|
||||
] : []
|
||||
}))
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
renderSettings();
|
||||
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));
|
||||
renderSettings();
|
||||
document.getElementById('loadingIndicator').classList.add('hidden');
|
||||
document.getElementById('settingsContainer').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// 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>
|
||||
<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 class="grid grid-cols-1 md:grid-cols-3 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="flex items-center space-x-2 mt-6">
|
||||
<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>
|
||||
</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">
|
||||
×
|
||||
</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]);
|
||||
}
|
||||
};
|
||||
|
||||
el.removeEventListener('change', handler);
|
||||
el.removeEventListener('input', handler);
|
||||
el.addEventListener('change', handler);
|
||||
el.addEventListener('input', handler);
|
||||
});
|
||||
|
||||
// Add/Remove choice buttons
|
||||
document.querySelectorAll('[data-action]').forEach(btn => {
|
||||
const handler = (e) => {
|
||||
const action = e.target.dataset.action;
|
||||
const colIndex = parseInt(e.target.dataset.colIndex);
|
||||
|
||||
console.log('Action:', action, 'colIndex:', colIndex);
|
||||
|
||||
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();
|
||||
} 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();
|
||||
}
|
||||
};
|
||||
|
||||
btn.removeEventListener('click', handler);
|
||||
btn.addEventListener('click', handler);
|
||||
});
|
||||
}
|
||||
|
||||
// Save settings
|
||||
document.getElementById('saveBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
// Update global settings
|
||||
currentSettings.rowHeight = parseInt(document.getElementById('rowHeight').value);
|
||||
currentSettings.fontSize = parseInt(document.getElementById('fontSize').value);
|
||||
currentSettings.stripedRows = document.getElementById('stripedRows').checked;
|
||||
|
||||
console.log('Saving settings:', currentSettings);
|
||||
console.log('Settings record ID:', settingsRecordId);
|
||||
|
||||
const jsonString = JSON.stringify(currentSettings);
|
||||
console.log('JSON string length:', jsonString.length);
|
||||
|
||||
// Save to PocketBase
|
||||
const updated = await pb.collection('app_preferences_settings').update(settingsRecordId, {
|
||||
app_json_prefs: jsonString,
|
||||
app_txt_prefs: `Updated on ${new Date().toISOString()}`
|
||||
});
|
||||
|
||||
console.log('Settings saved successfully:', updated);
|
||||
alert('Settings saved successfully!');
|
||||
window.location.href = '/estimatortable.html';
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
console.error('Error details:', error.message, error.stack);
|
||||
alert('Error saving settings: ' + error.message + '. Please try again.');
|
||||
}
|
||||
});
|
||||
|
||||
// Reset to defaults
|
||||
document.getElementById('resetBtn').addEventListener('click', () => {
|
||||
if (confirm('Are you sure you want to reset all settings to defaults?')) {
|
||||
currentSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||
renderSettings();
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel/Close buttons
|
||||
document.getElementById('cancelBtn').addEventListener('click', () => {
|
||||
window.location.href = '/estimatortable.html';
|
||||
});
|
||||
|
||||
document.getElementById('closeBtn').addEventListener('click', () => {
|
||||
window.location.href = '/estimatortable.html';
|
||||
});
|
||||
|
||||
// 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>
|
||||
@@ -0,0 +1,755 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Estimator Table</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#4c51bf',
|
||||
secondary: '#5b21b6',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
/* Fixed layout - no outer scroll */
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Container layout - keep header stationary */
|
||||
#tableContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
padding: 1rem;
|
||||
padding-top: 3.5rem; /* Space for user display */
|
||||
}
|
||||
|
||||
/* Fixed header section */
|
||||
.header-section {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Scrollable table area with always-visible scrollbars */
|
||||
.table-container {
|
||||
position: relative;
|
||||
overflow: scroll; /* Always show scrollbars */
|
||||
flex: 1;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
min-height: 0; /* Critical for flex child scrolling */
|
||||
}
|
||||
|
||||
/* Force scrollbars to always be visible */
|
||||
.table-container::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.table-container::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.table-container::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.table-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed; /* Fixed table layout for consistent column widths */
|
||||
}
|
||||
|
||||
/* Fixed column widths */
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* Freeze header row */
|
||||
.data-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: linear-gradient(to bottom right, #4c51bf, #5b21b6);
|
||||
color: white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
border-right: 1px solid rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
/* Freeze first column (Job_Number) - fixed 120px width */
|
||||
.data-table th:nth-child(1),
|
||||
.data-table td:nth-child(1) {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 15;
|
||||
background: white;
|
||||
border-right: 2px solid #4c51bf !important;
|
||||
}
|
||||
|
||||
/* First column header - highest z-index */
|
||||
.data-table thead th:nth-child(1) {
|
||||
z-index: 35;
|
||||
background: linear-gradient(to bottom right, #4c51bf, #5b21b6);
|
||||
box-shadow: 2px 2px 4px rgba(0,0,0,0.2);
|
||||
border-right: 2px solid rgba(255,255,255,0.3) !important;
|
||||
}
|
||||
|
||||
/* Freeze second column (Job_Full_Name) - fixed 250px width */
|
||||
.data-table th:nth-child(2),
|
||||
.data-table td:nth-child(2) {
|
||||
position: sticky;
|
||||
left: 120px;
|
||||
z-index: 15;
|
||||
background: white;
|
||||
border-right: 2px solid #4c51bf !important;
|
||||
}
|
||||
|
||||
/* Second column header - highest z-index */
|
||||
.data-table thead th:nth-child(2) {
|
||||
z-index: 35;
|
||||
background: linear-gradient(to bottom right, #4c51bf, #5b21b6);
|
||||
box-shadow: 2px 2px 4px rgba(0,0,0,0.2);
|
||||
border-right: 2px solid rgba(255,255,255,0.3) !important;
|
||||
}
|
||||
|
||||
/* Alternating row colors - maintain on frozen columns */
|
||||
.data-table tbody tr:nth-child(even) td {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.data-table tbody tr:nth-child(even) td:nth-child(1),
|
||||
.data-table tbody tr:nth-child(even) td:nth-child(2) {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover td {
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover td:nth-child(1),
|
||||
.data-table tbody tr:hover td:nth-child(2) {
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
/* Prominent checkbox styling */
|
||||
.checkbox-cell input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: default;
|
||||
accent-color: #000;
|
||||
border: 2px solid #000;
|
||||
}
|
||||
|
||||
/* Override disabled appearance - keep checkboxes looking active */
|
||||
.checkbox-cell input[type="checkbox"]:disabled {
|
||||
opacity: 1;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-screen overflow-hidden bg-gradient-to-br from-primary to-secondary font-sans">
|
||||
|
||||
<!-- Top-right display name and settings -->
|
||||
<div id="userDisplay" class="hidden absolute top-4 right-4 flex items-center space-x-3">
|
||||
<span id="userDisplayName" class="text-white text-sm font-semibold drop-shadow"></span>
|
||||
<button id="settingsBtn" class="text-white hover:text-gray-200 transition-colors" title="Settings">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Login Container -->
|
||||
<div id="loginContainer" class="hidden max-w-md mx-auto bg-white rounded-2xl shadow-2xl p-8 mt-20">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold text-gray-800 mb-2">Estimator Table</h1>
|
||||
<p class="text-gray-600">Job Information</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<p class="text-gray-700 text-center">Sign in with your Microsoft account to access the table</p>
|
||||
<button type="button" id="loginBtn" class="w-full py-3 px-4 bg-gradient-to-br from-primary to-secondary text-white font-semibold rounded-lg hover:opacity-95 transition-opacity shadow-lg hover:shadow-xl">
|
||||
Login with Microsoft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Indicator -->
|
||||
<div id="loadingIndicator" class="hidden 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 data...</p>
|
||||
</div>
|
||||
|
||||
<!-- Table Container -->
|
||||
<div id="tableContainer" class="hidden">
|
||||
<div class="header-section bg-white rounded-lg shadow-2xl p-6">
|
||||
<h1 class="text-3xl font-bold text-gray-800">Estimator Job Table</h1>
|
||||
<p class="text-gray-600 mt-1">Scroll horizontally and vertically • First two columns are frozen</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-2xl p-4" style="display: flex; flex-direction: column; flex: 1; min-height: 0;">
|
||||
<div class="table-container">
|
||||
<table class="data-table w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Number</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Full Name</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Active</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Added To Calendar</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Attachment List</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Company Client</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Contact Person</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Docs Uploaded</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Due Date</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Due Date Counter</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Due Date Source</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Due Time</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Email</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Estimate Approved</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Estimate Draft</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Estimate Reviewed</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Estimator</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Flag</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Has Attachments</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Address</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Codes</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Division</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Folder Link</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Name</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Number Parent</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job QB Link</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Status</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Job Type</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Note Ref</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Notes</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Office Rep</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">PSwift Uploaded</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Phone Number</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Project Manager</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">QB Created</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Schedule Confidence</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Start Date</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Submission Date</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Tax Exempt</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Tax Exempt Docs Uploaded</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Voxer Created</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Voxer Link</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Last Note At</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Latest Note Summary</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Note Count</th>
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">Note Thread Text</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tableBody">
|
||||
<!-- Data will be inserted here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/pocketbase/dist/pocketbase.umd.js"></script>
|
||||
<script>
|
||||
// Initialize PocketBase client
|
||||
const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
||||
|
||||
// Column name to position mapping (based on actual table order)
|
||||
const COLUMN_ORDER = [
|
||||
'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', 'created'
|
||||
];
|
||||
|
||||
const loginContainer = document.getElementById('loginContainer');
|
||||
const tableContainer = document.getElementById('tableContainer');
|
||||
const loadingIndicator = document.getElementById('loadingIndicator');
|
||||
const userDisplay = document.getElementById('userDisplay');
|
||||
const userDisplayName = document.getElementById('userDisplayName');
|
||||
const loginBtn = document.getElementById('loginBtn');
|
||||
const tableBody = document.getElementById('tableBody');
|
||||
|
||||
const APP_NAME = 'Estimator Job List';
|
||||
let appSettings = null;
|
||||
|
||||
// Load app settings from PocketBase
|
||||
async function loadAppSettings() {
|
||||
try {
|
||||
const records = await pb.collection('app_preferences_settings').getFullList({
|
||||
filter: `app_name = "${APP_NAME}"`
|
||||
});
|
||||
|
||||
if (records.length > 0) {
|
||||
const settingsData = records[0].app_json_prefs;
|
||||
if (settingsData) {
|
||||
appSettings = typeof settingsData === 'string' ? JSON.parse(settingsData) : settingsData;
|
||||
console.log('Loaded app settings:', appSettings);
|
||||
applyGlobalSettings();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading app settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply global settings to table
|
||||
function applyGlobalSettings() {
|
||||
if (!appSettings) return;
|
||||
|
||||
// Remove any existing dynamic styles first
|
||||
const existingStyle = document.getElementById('dynamic-column-styles');
|
||||
if (existingStyle) {
|
||||
existingStyle.remove();
|
||||
}
|
||||
|
||||
let styleContent = '';
|
||||
|
||||
// Apply row height
|
||||
if (appSettings.rowHeight) {
|
||||
styleContent += `.data-table tbody tr { height: ${appSettings.rowHeight}px; }\n`;
|
||||
}
|
||||
|
||||
// Apply font size
|
||||
if (appSettings.fontSize) {
|
||||
styleContent += `.data-table { font-size: ${appSettings.fontSize}px; }\n`;
|
||||
}
|
||||
|
||||
// Apply striped rows setting
|
||||
if (appSettings.stripedRows === false) {
|
||||
styleContent += `.data-table tbody tr:nth-child(even) td { background: white !important; }\n`;
|
||||
}
|
||||
|
||||
// Apply column widths and visibility dynamically
|
||||
if (appSettings.columns) {
|
||||
const col1Settings = getColumnSettings('Job_Number');
|
||||
const col2Settings = getColumnSettings('Job_Full_Name');
|
||||
const col1Width = col1Settings?.width || 120;
|
||||
const col2Width = col2Settings?.width || 250;
|
||||
|
||||
console.log('Applying settings:', { col1Width, col2Width, columns: appSettings.columns.length });
|
||||
|
||||
// Update sticky positions and widths for first two columns
|
||||
styleContent += `
|
||||
.data-table th:nth-child(1), .data-table td:nth-child(1) {
|
||||
width: ${col1Width}px !important;
|
||||
min-width: ${col1Width}px !important;
|
||||
max-width: ${col1Width}px !important;
|
||||
}
|
||||
.data-table th:nth-child(2), .data-table td:nth-child(2) {
|
||||
left: ${col1Width}px !important;
|
||||
width: ${col2Width}px !important;
|
||||
min-width: ${col2Width}px !important;
|
||||
max-width: ${col2Width}px !important;
|
||||
}
|
||||
`;
|
||||
|
||||
// Calculate total width and apply settings to all columns
|
||||
let totalWidth = 0;
|
||||
|
||||
appSettings.columns.forEach((col) => {
|
||||
// Find the actual position in the table
|
||||
const colPosition = COLUMN_ORDER.indexOf(col.name) + 1;
|
||||
|
||||
if (colPosition === 0) {
|
||||
console.warn(`Column ${col.name} not found in COLUMN_ORDER`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply width for all columns
|
||||
if (col.width) {
|
||||
styleContent += `
|
||||
.data-table th:nth-child(${colPosition}),
|
||||
.data-table td:nth-child(${colPosition}) {
|
||||
width: ${col.width}px !important;
|
||||
min-width: ${col.width}px !important;
|
||||
max-width: ${col.width}px !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// Hide columns marked as not visible
|
||||
if (col.visible === false) {
|
||||
console.log(`Hiding column ${col.name} at position ${colPosition}`);
|
||||
styleContent += `
|
||||
.data-table th:nth-child(${colPosition}),
|
||||
.data-table td:nth-child(${colPosition}) {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
} else {
|
||||
// Add to total width for visible columns
|
||||
totalWidth += col.width || 150;
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure table is wide enough to trigger horizontal scroll
|
||||
console.log(`Total table width: ${totalWidth}px`);
|
||||
styleContent += `
|
||||
.data-table {
|
||||
width: ${totalWidth}px !important;
|
||||
min-width: ${totalWidth}px !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'dynamic-column-styles';
|
||||
style.textContent = styleContent;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Get column settings
|
||||
function getColumnSettings(columnName) {
|
||||
if (!appSettings || !appSettings.columns) return null;
|
||||
return appSettings.columns.find(col => col.name === columnName);
|
||||
}
|
||||
|
||||
// Get choice styling for a value
|
||||
function getChoiceStyling(columnName, value) {
|
||||
const colSettings = getColumnSettings(columnName);
|
||||
if (!colSettings || !colSettings.choices) return null;
|
||||
|
||||
const choice = colSettings.choices.find(c => c.value === value);
|
||||
return choice || null;
|
||||
}
|
||||
|
||||
// Check if user is already authenticated
|
||||
async function checkAuth() {
|
||||
if (pb.authStore.isValid) {
|
||||
// Already logged in
|
||||
await loadAppSettings();
|
||||
showTable();
|
||||
await loadTableData();
|
||||
} else {
|
||||
// Need to login
|
||||
loginContainer.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Login with Microsoft
|
||||
loginBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = 'Logging in...';
|
||||
|
||||
const authData = await pb.collection('users').authWithOAuth2({ provider: 'microsoft' });
|
||||
|
||||
await loadAppSettings();
|
||||
showTable();
|
||||
await loadTableData();
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
alert('Login failed. Please try again.');
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = 'Login with Microsoft';
|
||||
}
|
||||
});
|
||||
|
||||
function showTable() {
|
||||
loginContainer.classList.add('hidden');
|
||||
tableContainer.classList.remove('hidden');
|
||||
|
||||
if (pb.authStore.model) {
|
||||
userDisplay.classList.remove('hidden');
|
||||
userDisplayName.textContent = pb.authStore.model.name || pb.authStore.model.email || 'User';
|
||||
}
|
||||
}
|
||||
|
||||
// Settings button handler
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.closest('#settingsBtn')) {
|
||||
window.location.href = '/appsettings.html';
|
||||
}
|
||||
});
|
||||
|
||||
// Format date from UTC to Central Time (MM/dd/yyyy)
|
||||
function formatDate(dateString, notesField = null) {
|
||||
// Check for sentinel date (null date representation)
|
||||
if (!dateString || dateString === '1900-01-01T00:00:00.000Z' || dateString.startsWith('1900-01-01')) {
|
||||
// If there's a related notes field with content, show "See Notes"
|
||||
if (notesField && notesField.trim()) {
|
||||
return 'See Notes';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
// Format in Central Time
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/Chicago',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
return formatter.format(date);
|
||||
} catch (error) {
|
||||
console.error('Date format error:', error, dateString);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Format datetime with time (MM/dd/yyyy h:mm am/pm)
|
||||
function formatDateTime(dateString) {
|
||||
if (!dateString || dateString === '1900-01-01T00:00:00.000Z' || dateString.startsWith('1900-01-01')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const dateFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/Chicago',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
const timeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/Chicago',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
return `${dateFormatter.format(date)} ${timeFormatter.format(date)}`;
|
||||
} catch (error) {
|
||||
console.error('DateTime format error:', error, dateString);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate Due_Date_Counter based on .cursorrules logic
|
||||
function calculateDueDateCounter(record) {
|
||||
const jobStatus = record.Job_Status;
|
||||
const dueDate = record.Due_Date;
|
||||
const dueDateASAP = record.Due_Date_ASAP; // Will be undefined until schema updated
|
||||
|
||||
// 1. If Job_Status ≠ "Estimating" → "N/A"
|
||||
if (jobStatus !== 'Estimating') {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
// 2. If Due_Date_ASAP = true → "ASAP"
|
||||
if (dueDateASAP === true) {
|
||||
return 'ASAP';
|
||||
}
|
||||
|
||||
// 3. If Due_Date is blank/sentinel → blank/empty
|
||||
if (!dueDate || dueDate === '1900-01-01T00:00:00.000Z' || dueDate.startsWith('1900-01-01')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const due = new Date(dueDate);
|
||||
const today = new Date();
|
||||
|
||||
// Convert both to Central Time midnight for comparison
|
||||
const dueMidnight = new Date(due.toLocaleString('en-US', { timeZone: 'America/Chicago' }));
|
||||
dueMidnight.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayMidnight = new Date(today.toLocaleString('en-US', { timeZone: 'America/Chicago' }));
|
||||
todayMidnight.setHours(0, 0, 0, 0);
|
||||
|
||||
const diffTime = dueMidnight - todayMidnight;
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
// 4. If Due_Date < Today → "Passed due"
|
||||
if (diffDays < 0) {
|
||||
return 'Passed due';
|
||||
}
|
||||
|
||||
// 5. If Due_Date = Today → "Due today"
|
||||
if (diffDays === 0) {
|
||||
return 'Due today';
|
||||
}
|
||||
|
||||
// 6. Else → "[X] day" or "[X] days"
|
||||
return `${diffDays} ${diffDays === 1 ? 'day' : 'days'}`;
|
||||
} catch (error) {
|
||||
console.error('Due date counter calculation error:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Format boolean values as prominent checkboxes (read-only)
|
||||
function formatBoolean(value) {
|
||||
return value ? '✓' : '';
|
||||
}
|
||||
|
||||
// Format Active field as read-only checkbox with alert
|
||||
function formatActiveCheckbox(value, recordId) {
|
||||
return `<div class="checkbox-cell"><input type="checkbox" ${value ? 'checked' : ''} data-field="Active" data-readonly="true" data-record-id="${recordId}"></div>`;
|
||||
}
|
||||
|
||||
// Format editable boolean as checkbox element
|
||||
function formatEditableCheckbox(value, fieldName, recordId) {
|
||||
return `<div class="checkbox-cell"><input type="checkbox" ${value ? 'checked' : ''} data-field="${fieldName}" data-record-id="${recordId}"></div>`;
|
||||
}
|
||||
|
||||
// Load table data from PocketBase
|
||||
async function loadTableData() {
|
||||
try {
|
||||
loadingIndicator.classList.remove('hidden');
|
||||
|
||||
// Fetch all records from Job_Info_TestEnv collection
|
||||
const records = await pb.collection('Job_Info_TestEnv').getFullList({
|
||||
sort: 'Job_Number',
|
||||
});
|
||||
|
||||
// Clear existing rows
|
||||
tableBody.innerHTML = '';
|
||||
|
||||
// Populate table
|
||||
records.forEach(record => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'border-b border-gray-200';
|
||||
|
||||
// Helper to get cell style based on column settings and value
|
||||
const getCellStyle = (columnName, value) => {
|
||||
let style = '';
|
||||
const colSettings = getColumnSettings(columnName);
|
||||
|
||||
if (colSettings) {
|
||||
// Check for choice-based styling
|
||||
if (value && colSettings.choices && colSettings.choices.length > 0) {
|
||||
const choice = colSettings.choices.find(c => c.value === value);
|
||||
if (choice) {
|
||||
// Use alternating colors based on row index
|
||||
const rowIndex = Array.from(tableBody.children).length;
|
||||
const bgColor = rowIndex % 2 === 0 ? choice.bgColor : choice.bgColorAlt;
|
||||
style += `background-color: ${bgColor} !important;`;
|
||||
|
||||
if (choice.fontStyle === 'bold') {
|
||||
style += 'font-weight: bold;';
|
||||
} else if (choice.fontStyle === 'italic') {
|
||||
style += 'font-style: italic;';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return style ? `style="${style}"` : '';
|
||||
};
|
||||
|
||||
row.innerHTML = `
|
||||
<td class="px-4 py-3 border-r border-gray-200 whitespace-nowrap font-medium" ${getCellStyle('Job_Number', record.Job_Number)}>${record.Job_Number || ''}</td>
|
||||
<td class="px-4 py-3 border-r border-gray-200 whitespace-nowrap" ${getCellStyle('Job_Full_Name', record.Job_Full_Name)}>${record.Job_Full_Name || ''}</td>
|
||||
<td class="px-4 py-3 text-center" ${getCellStyle('Active', record.Active)}>${formatActiveCheckbox(record.Active, record.id)}</td>
|
||||
<td class="px-4 py-3 text-center" ${getCellStyle('Added_To_Calendar', record.Added_To_Calendar)}>${formatEditableCheckbox(record.Added_To_Calendar, 'Added_To_Calendar', record.id)}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Attachment_List', record.Attachment_List)}>${record.Attachment_List || ''}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Company_Client', record.Company_Client)}>${record.Company_Client || ''}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Contact_Person', record.Contact_Person)}>${record.Contact_Person || ''}</td>
|
||||
<td class="px-4 py-3 text-center" ${getCellStyle('Docs_Uploaded', record.Docs_Uploaded)}>${formatEditableCheckbox(record.Docs_Uploaded, 'Docs_Uploaded', record.id)}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap" ${getCellStyle('Due_Date', record.Due_Date)}>${formatDate(record.Due_Date, record.Due_Date_Notes)}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Due_Date_Counter', record.Due_Date_Counter)}>${calculateDueDateCounter(record)}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Due_Date_Source', record.Due_Date_Source)}>${record.Due_Date_Source || ''}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Due_Time', record.Due_Time)}>${record.Due_Time || ''}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Email', record.Email)}>${record.Email || ''}</td>
|
||||
<td class="px-4 py-3 text-center" ${getCellStyle('Estimate_Approved', record.Estimate_Approved)}>${formatEditableCheckbox(record.Estimate_Approved, 'Estimate_Approved', record.id)}</td>
|
||||
<td class="px-4 py-3 text-center" ${getCellStyle('Estimate_Draft', record.Estimate_Draft)}>${formatEditableCheckbox(record.Estimate_Draft, 'Estimate_Draft', record.id)}</td>
|
||||
<td class="px-4 py-3 text-center" ${getCellStyle('Estimate_Reviewed', record.Estimate_Reviewed)}>${formatEditableCheckbox(record.Estimate_Reviewed, 'Estimate_Reviewed', record.id)}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Estimator', record.Estimator)}>${record.Estimator || ''}</td>
|
||||
<td class="px-4 py-3 text-center" ${getCellStyle('Flag', record.Flag)}>${formatEditableCheckbox(record.Flag, 'Flag', record.id)}</td>
|
||||
<td class="px-4 py-3 text-center" ${getCellStyle('Has_Attachments', record.Has_Attachments)}>${formatEditableCheckbox(record.Has_Attachments, 'Has_Attachments', record.id)}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Job_Address', record.Job_Address)}>${record.Job_Address || ''}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Job_Codes', record.Job_Codes)}>${record.Job_Codes || ''}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Job_Division', record.Job_Division)}>${record.Job_Division || ''}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Job_Folder_Link', record.Job_Folder_Link)}>${record.Job_Folder_Link || ''}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Job_Name', record.Job_Name)}>${record.Job_Name || ''}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Job_Number_Parent', record.Job_Number_Parent)}>${record.Job_Number_Parent || ''}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Job_QB_Link', record.Job_QB_Link)}>${record.Job_QB_Link || ''}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Job_Status', record.Job_Status)}>${record.Job_Status || ''}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Job_Type', record.Job_Type)}>${record.Job_Type || ''}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Note_Ref', record.Note_Ref)}>${record.Note_Ref || ''}</td>
|
||||
<td class="px-4 py-3 max-w-xs truncate" title="${record.Notes || ''}" ${getCellStyle('Notes', record.Notes)}>${record.Notes || ''}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Office_Rep', record.Office_Rep)}>${record.Office_Rep || ''}</td>
|
||||
<td class="px-4 py-3 text-center" ${getCellStyle('PSwift_Uploaded', record.PSwift_Uploaded)}>${formatEditableCheckbox(record.PSwift_Uploaded, 'PSwift_Uploaded', record.id)}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Phone_Number', record.Phone_Number)}>${record.Phone_Number || ''}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Project_Manager', record.Project_Manager)}>${record.Project_Manager || ''}</td>
|
||||
<td class="px-4 py-3 text-center" ${getCellStyle('QB_Created', record.QB_Created)}>${formatEditableCheckbox(record.QB_Created, 'QB_Created', record.id)}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Schedule_Confidence', record.Schedule_Confidence)}>${record.Schedule_Confidence || ''}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap" ${getCellStyle('Start_Date', record.Start_Date)}>${formatDate(record.Start_Date, record.Start_Date_Notes)}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap" ${getCellStyle('Submission_Date', record.Submission_Date)}>${formatDate(record.Submission_Date)}</td>
|
||||
<td class="px-4 py-3 text-center" ${getCellStyle('Tax_Exempt', record.Tax_Exempt)}>${formatEditableCheckbox(record.Tax_Exempt, 'Tax_Exempt', record.id)}</td>
|
||||
<td class="px-4 py-3 text-center" ${getCellStyle('Tax_Exempt_Docs_Uploaded', record.Tax_Exempt_Docs_Uploaded)}>${formatEditableCheckbox(record.Tax_Exempt_Docs_Uploaded, 'Tax_Exempt_Docs_Uploaded', record.id)}</td>
|
||||
<td class="px-4 py-3 text-center" ${getCellStyle('Voxer_Created', record.Voxer_Created)}>${formatEditableCheckbox(record.Voxer_Created, 'Voxer_Created', record.id)}</td>
|
||||
<td class="px-4 py-3" ${getCellStyle('Voxer_Link', record.Voxer_Link)}>${record.Voxer_Link || ''}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap" ${getCellStyle('last_note_at', record.last_note_at)}>${formatDateTime(record.last_note_at)}</td>
|
||||
<td class="px-4 py-3 max-w-xs truncate" title="${record.latest_note_summary || ''}" ${getCellStyle('latest_note_summary', record.latest_note_summary)}>${record.latest_note_summary || ''}</td>
|
||||
<td class="px-4 py-3 text-center" ${getCellStyle('note_count', record.note_count)}>${record.note_count || ''}</td>
|
||||
<td class="px-4 py-3 max-w-xs truncate" title="${record.note_thread_text || ''}" ${getCellStyle('note_thread_text', record.note_thread_text)}>${record.note_thread_text || ''}</td>
|
||||
`;
|
||||
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
|
||||
loadingIndicator.classList.add('hidden');
|
||||
} catch (error) {
|
||||
console.error('Error loading table data:', error);
|
||||
loadingIndicator.classList.add('hidden');
|
||||
alert('Error loading data. Please refresh the page.');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle checkbox changes
|
||||
document.addEventListener('change', async (e) => {
|
||||
if (e.target.type === 'checkbox' && e.target.dataset.field) {
|
||||
const checkbox = e.target;
|
||||
const fieldName = checkbox.dataset.field;
|
||||
const recordId = checkbox.dataset.recordId;
|
||||
const isReadOnly = checkbox.dataset.readonly === 'true';
|
||||
|
||||
// Handle read-only Active field
|
||||
if (isReadOnly && fieldName === 'Active') {
|
||||
// Revert the checkbox state
|
||||
checkbox.checked = !checkbox.checked;
|
||||
alert('Active status is calculated automatically by selected Job Status values.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update PocketBase for editable fields
|
||||
const newValue = checkbox.checked;
|
||||
|
||||
try {
|
||||
await pb.collection('Job_Info_TestEnv').update(recordId, {
|
||||
[fieldName]: newValue
|
||||
});
|
||||
console.log(`Updated ${fieldName} to ${newValue} for record ${recordId}`);
|
||||
} catch (error) {
|
||||
console.error('Error updating field:', error);
|
||||
// Revert checkbox on error
|
||||
checkbox.checked = !checkbox.checked;
|
||||
alert('Failed to update field. Please try again.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
checkAuth();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -99,12 +99,27 @@ app.post('/api/submit', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Serve static index.html
|
||||
// Serve static files
|
||||
app.get('/', async (c) => {
|
||||
const html = await Bun.file('index.html').text();
|
||||
return c.html(html);
|
||||
});
|
||||
|
||||
app.get('/estimatortable.html', async (c) => {
|
||||
const html = await Bun.file('estimatortable.html').text();
|
||||
return c.html(html);
|
||||
});
|
||||
|
||||
app.get('/admin.html', async (c) => {
|
||||
const html = await Bun.file('admin.html').text();
|
||||
return c.html(html);
|
||||
});
|
||||
|
||||
app.get('/appsettings.html', async (c) => {
|
||||
const html = await Bun.file('appsettings.html').text();
|
||||
return c.html(html);
|
||||
});
|
||||
|
||||
// Admin login endpoint
|
||||
app.post('/api/admin/login', async (c) => {
|
||||
try {
|
||||
@@ -480,9 +495,12 @@ app.get('/api/export/schema', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = Number(process.env.PORT || 7500);
|
||||
const PORT = Number(process.env.PORT || 6500);
|
||||
|
||||
console.log(`Idea & Feedback Form server running at http://localhost:${PORT}`);
|
||||
console.log(`\n🚀 Server running on port ${PORT}`);
|
||||
console.log(`\n📊 Estimator Table: \x1b[36mhttp://localhost:${PORT}/estimatortable.html\x1b[0m`);
|
||||
console.log(`📝 Idea & Feedback Form: \x1b[36mhttp://localhost:${PORT}/index.html\x1b[0m`);
|
||||
console.log(`👤 Admin Panel: \x1b[36mhttp://localhost:${PORT}/admin.html\x1b[0m\n`);
|
||||
|
||||
export default {
|
||||
port: PORT,
|
||||
|
||||
Reference in New Issue
Block a user