Update to v1.0.0-alpha5
- Add hover tooltip column setting for truncated text - Update Tax_Exempt_Docs_Uploaded label to 'Tax Exempt Uploads?'
This commit is contained in:
+150
-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>
|
||||
@@ -215,6 +276,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) {
|
||||
@@ -349,6 +417,14 @@
|
||||
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');
|
||||
@@ -388,10 +464,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>
|
||||
|
||||
@@ -604,6 +740,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) {
|
||||
|
||||
+543
-21
@@ -85,6 +85,24 @@
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Enforce fixed row heights */
|
||||
.data-table tbody tr {
|
||||
height: 40px;
|
||||
max-height: 40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-table tbody td {
|
||||
height: 40px;
|
||||
max-height: 40px;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Freeze header row */
|
||||
.data-table thead th {
|
||||
position: sticky;
|
||||
@@ -94,6 +112,7 @@
|
||||
color: white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
border-right: 1px solid rgba(255,255,255,0.2);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* Freeze first column (Job_Number) - fixed 120px width */
|
||||
@@ -152,12 +171,21 @@
|
||||
}
|
||||
|
||||
/* Prominent checkbox styling */
|
||||
.checkbox-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.checkbox-cell input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: default;
|
||||
accent-color: #000;
|
||||
border: 2px solid #000;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Override disabled appearance - keep checkboxes looking active */
|
||||
@@ -298,7 +326,7 @@
|
||||
<!-- Footer -->
|
||||
<div class="mt-2 flex justify-between items-center text-white text-xs px-4">
|
||||
<span id="footerEmail"></span>
|
||||
<span>v1.0.0-alpha4</span>
|
||||
<span>v1.0.0-alpha5</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -368,6 +396,10 @@
|
||||
}
|
||||
|
||||
function prettyLabel(name) {
|
||||
// Special case for Tax_Exempt_Docs_Uploaded
|
||||
if (name === 'Tax_Exempt_Docs_Uploaded') {
|
||||
return 'Tax Exempt Uploads?';
|
||||
}
|
||||
return name.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
@@ -456,9 +488,9 @@
|
||||
console.log('App settings updated via realtime', { action: e.action });
|
||||
applyGlobalSettings();
|
||||
scheduleTableReload();
|
||||
});
|
||||
}, { /* Silently handle connection errors */ });
|
||||
} catch (err) {
|
||||
console.error('Failed to start settings watch', err);
|
||||
// Silently ignore realtime connection errors (QUIC protocol issues are cosmetic)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,9 +503,12 @@
|
||||
}
|
||||
const visibleColumns = getVisibleColumns();
|
||||
console.log('Rendering header with', visibleColumns.length, 'columns');
|
||||
headerRow.innerHTML = visibleColumns.map(col => `
|
||||
<th class="px-4 py-3 text-left font-semibold whitespace-nowrap">${prettyLabel(col.name)}</th>
|
||||
`).join('');
|
||||
headerRow.innerHTML = visibleColumns.map(col => {
|
||||
const colSettings = getColumnSettings(col.name);
|
||||
const alignment = colSettings?.alignment || 'left';
|
||||
const wrapClass = colSettings?.headerWrap ? 'whitespace-normal break-words' : 'whitespace-nowrap';
|
||||
return `<th class="px-4 py-3 text-${alignment} font-semibold ${wrapClass}">${prettyLabel(col.name)}</th>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Apply global settings to table (styles)
|
||||
@@ -487,7 +522,32 @@
|
||||
|
||||
// Apply row height
|
||||
if (appSettings && appSettings.rowHeight) {
|
||||
styleContent += `.data-table tbody tr { height: ${appSettings.rowHeight}px; }\n`;
|
||||
const height = appSettings.rowHeight;
|
||||
const cellVerticalAlign = appSettings.cellVerticalAlign || 'middle';
|
||||
styleContent += `
|
||||
.data-table tbody tr {
|
||||
height: ${height}px !important;
|
||||
max-height: ${height}px !important;
|
||||
}
|
||||
.data-table tbody td {
|
||||
height: ${height}px !important;
|
||||
max-height: ${height}px !important;
|
||||
overflow: hidden !important;
|
||||
vertical-align: ${cellVerticalAlign} !important;
|
||||
padding: ${cellVerticalAlign === 'top' ? '4px 16px' : (cellVerticalAlign === 'bottom' ? '0 16px 4px 16px' : '0 16px')} !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// Apply header vertical alignment
|
||||
if (appSettings && appSettings.headerVerticalAlign) {
|
||||
const headerAlign = appSettings.headerVerticalAlign;
|
||||
styleContent += `
|
||||
.data-table thead th {
|
||||
vertical-align: ${headerAlign} !important;
|
||||
padding: ${headerAlign === 'top' ? '8px 16px 4px 16px' : (headerAlign === 'bottom' ? '4px 16px 8px 16px' : '12px 16px')} !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// Apply font size
|
||||
@@ -500,6 +560,31 @@
|
||||
styleContent += `.data-table tbody tr:nth-child(even) td { background: white !important; }\n`;
|
||||
}
|
||||
|
||||
// Apply border settings
|
||||
if (appSettings) {
|
||||
const borderColor = appSettings.borderColor || '#e5e7eb';
|
||||
const borderWidth = appSettings.borderWidth || 1;
|
||||
const showRowBorders = appSettings.showRowBorders !== false;
|
||||
const showColumnBorders = appSettings.showColumnBorders !== false;
|
||||
|
||||
styleContent += `
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
border-right: ${showColumnBorders ? `${borderWidth}px solid ${borderColor}` : 'none'} !important;
|
||||
border-bottom: ${showRowBorders ? `${borderWidth}px solid ${borderColor}` : 'none'} !important;
|
||||
}
|
||||
`;
|
||||
|
||||
// Keep header borders slightly different if they exist
|
||||
if (showColumnBorders) {
|
||||
styleContent += `
|
||||
.data-table thead th {
|
||||
border-right: ${borderWidth}px solid rgba(255,255,255,0.2) !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Column widths and sticky headers based on visible order
|
||||
if (visibleColumns.length) {
|
||||
const col1Width = visibleColumns[0]?.width || 120;
|
||||
@@ -526,6 +611,9 @@
|
||||
let totalWidth = 0;
|
||||
visibleColumns.forEach((col, idx) => {
|
||||
const width = col.width || 150;
|
||||
const colSettings = getColumnSettings(col.name);
|
||||
const alignment = colSettings?.alignment || 'left';
|
||||
const headerWrap = colSettings?.headerWrap;
|
||||
totalWidth += width;
|
||||
styleContent += `
|
||||
.data-table th:nth-child(${idx + 1}),
|
||||
@@ -533,8 +621,26 @@
|
||||
width: ${width}px !important;
|
||||
min-width: ${width}px !important;
|
||||
max-width: ${width}px !important;
|
||||
text-align: ${alignment};
|
||||
}
|
||||
`;
|
||||
|
||||
// Apply header wrapping if enabled
|
||||
if (headerWrap) {
|
||||
styleContent += `
|
||||
.data-table thead th:nth-child(${idx + 1}) {
|
||||
white-space: normal !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
`;
|
||||
} else {
|
||||
styleContent += `
|
||||
.data-table thead th:nth-child(${idx + 1}) {
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
styleContent += `
|
||||
@@ -855,6 +961,8 @@
|
||||
return formatEditableCheckbox(record.Flag, 'Flag', record.id);
|
||||
case 'Has_Attachments':
|
||||
return formatEditableCheckbox(record.Has_Attachments, 'Has_Attachments', record.id);
|
||||
case 'Job_Folder_Link':
|
||||
return value ? `<a href="${value}" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-800 underline">${value}</a>` : '';
|
||||
case 'Notes':
|
||||
return record.Notes || '';
|
||||
case 'PSwift_Uploaded':
|
||||
@@ -880,6 +988,16 @@
|
||||
|
||||
// Title attributes for tooltips on truncated cells
|
||||
function getCellTitle(columnName, record) {
|
||||
// Check if column has hover tooltip enabled
|
||||
const colSettings = getColumnSettings(columnName);
|
||||
if (colSettings && colSettings.showFullTextOnHover) {
|
||||
const value = record[columnName];
|
||||
if (value !== null && value !== undefined) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Default behavior for specific columns
|
||||
switch (columnName) {
|
||||
case 'Notes':
|
||||
return record.Notes || '';
|
||||
@@ -919,6 +1037,14 @@
|
||||
}
|
||||
|
||||
allRecords = result.data; // Store all records for filtering
|
||||
|
||||
// Sort by Job_Number descending (highest to lowest) by default
|
||||
allRecords.sort((a, b) => {
|
||||
const numA = parseInt(a.Job_Number) || 0;
|
||||
const numB = parseInt(b.Job_Number) || 0;
|
||||
return numB - numA; // Descending order
|
||||
});
|
||||
|
||||
console.log(`Loaded ${allRecords.length} jobs ${result.cached ? 'from cache' : 'from PocketBase'}`);
|
||||
console.log('First record:', allRecords[0]);
|
||||
|
||||
@@ -970,7 +1096,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
filteredRecords.forEach(record => {
|
||||
filteredRecords.forEach((record, recordIndex) => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'border-b border-gray-200';
|
||||
|
||||
@@ -983,6 +1109,8 @@
|
||||
const className = columnClassMap[columnName] || 'px-4 py-3';
|
||||
let style = '';
|
||||
const colSettings = getColumnSettings(columnName);
|
||||
|
||||
// Apply choice-based styling
|
||||
if (colSettings && value && colSettings.choices && colSettings.choices.length > 0) {
|
||||
const choice = colSettings.choices.find(c => c.value === value);
|
||||
if (choice) {
|
||||
@@ -995,14 +1123,56 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
// Apply boolean-based styling for checkbox fields
|
||||
else if (colSettings && (value === true || value === false)) {
|
||||
const bgColor = rowIndex % 2 === 0
|
||||
? (value ? (colSettings.trueBgColor || '#d1fae5') : (colSettings.falseBgColor || '#fee2e2'))
|
||||
: (value ? (colSettings.trueBgColorAlt || '#a7f3d0') : (colSettings.falseBgColorAlt || '#fecaca'));
|
||||
style += `background-color: ${bgColor} !important;`;
|
||||
}
|
||||
|
||||
const styleAttr = style ? `style="${style}"` : '';
|
||||
const titleAttr = titleValue ? ` title="${titleValue}"` : '';
|
||||
const content = renderCellContent(columnName, record, value);
|
||||
return `<td class="${className}" ${styleAttr}${titleAttr}>${content}</td>`;
|
||||
|
||||
// Make cell editable if appropriate
|
||||
const isEditable = isFieldEditable(columnName, colSettings);
|
||||
|
||||
// For date fields, normalize the value to YYYY-MM-DD format for the date input
|
||||
let dataValue = (value || '').toString().replace(/"/g, '"');
|
||||
if (isEditable && ['Due_Date', 'Start_Date', 'Submission_Date'].includes(columnName) && value) {
|
||||
// Convert ISO date to YYYY-MM-DD
|
||||
try {
|
||||
const dateObj = new Date(value);
|
||||
if (!isNaN(dateObj.getTime())) {
|
||||
const year = dateObj.getFullYear();
|
||||
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(dateObj.getDate()).padStart(2, '0');
|
||||
dataValue = `${year}-${month}-${day}`;
|
||||
}
|
||||
} catch (e) {
|
||||
// Keep original value if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
const editableAttr = isEditable ? ` data-editable="true" data-record-id="${record.id}" data-field="${columnName}" data-value="${dataValue}" style="cursor: pointer; ${style}"` : styleAttr;
|
||||
|
||||
return `<td class="${className}" ${editableAttr}${titleAttr}>${content}</td>`;
|
||||
}).join('');
|
||||
|
||||
row.innerHTML = cellsHtml;
|
||||
tableBody.appendChild(row);
|
||||
|
||||
// Focus first Job_Number cell on initial load (first row only)
|
||||
if (recordIndex === 0 && currentSearchTerm === '') {
|
||||
setTimeout(() => {
|
||||
const firstJobNumberCell = tableBody.querySelector('tr:first-child td:first-child');
|
||||
if (firstJobNumberCell) {
|
||||
firstJobNumberCell.setAttribute('tabindex', '0');
|
||||
firstJobNumberCell.focus();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1036,6 +1206,324 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Determine if a field is editable
|
||||
function isFieldEditable(columnName, colSettings) {
|
||||
// Checkboxes are already handled by existing checkbox handler
|
||||
const checkboxFields = ['Active', 'Added_To_Calendar', 'Docs_Uploaded', 'Estimate_Approved',
|
||||
'Estimate_Draft', 'Estimate_Reviewed', 'Flag', 'Has_Attachments',
|
||||
'PSwift_Uploaded', 'QB_Created', 'Tax_Exempt', 'Tax_Exempt_Docs_Uploaded',
|
||||
'Voxer_Created'];
|
||||
if (checkboxFields.includes(columnName)) return false;
|
||||
|
||||
// Don't allow editing calculated/display fields
|
||||
const nonEditableFields = ['Job_Number', 'Job_Full_Name', 'Active', 'Due_Date_Counter',
|
||||
'last_note_at', 'note_count', 'latest_note_summary', 'note_thread_text'];
|
||||
if (nonEditableFields.includes(columnName)) return false;
|
||||
|
||||
// Allow editing for fields with choices or text fields
|
||||
const editableFields = ['Job_Type', 'Job_Status', 'Job_Division', 'Estimator', 'Office_Rep',
|
||||
'Project_Manager', 'Due_Date', 'Start_Date', 'Submission_Date',
|
||||
'Notes', 'Job_Name', 'Job_Address', 'Company_Client', 'Contact_Person',
|
||||
'Phone_Number', 'Due_Date_Notes', 'Start_Date_Notes'];
|
||||
|
||||
return editableFields.includes(columnName);
|
||||
}
|
||||
|
||||
// Normalize date input to YYYY-MM-DD format
|
||||
function normalizeDate(input) {
|
||||
if (!input || input.trim() === '') return null;
|
||||
|
||||
try {
|
||||
// Try parsing the input
|
||||
const date = new Date(input);
|
||||
if (isNaN(date.getTime())) return null;
|
||||
|
||||
// Format as YYYY-MM-DD
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create inline confirmation dialog
|
||||
function createConfirmDialog(message, onConfirm, onCancel, anchorElement) {
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'absolute bg-white border-2 border-blue-500 rounded shadow-lg p-3 z-[2000]';
|
||||
dialog.style.minWidth = '200px';
|
||||
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = 'text-sm mb-3 text-gray-800';
|
||||
messageDiv.textContent = message;
|
||||
|
||||
const buttonContainer = document.createElement('div');
|
||||
buttonContainer.className = 'flex gap-2 justify-end';
|
||||
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.textContent = 'Confirm';
|
||||
confirmBtn.className = 'px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm';
|
||||
confirmBtn.onclick = () => {
|
||||
dialog.remove();
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
cancelBtn.className = 'px-3 py-1 bg-gray-300 text-gray-700 rounded hover:bg-gray-400 text-sm';
|
||||
cancelBtn.onclick = () => {
|
||||
dialog.remove();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
buttonContainer.appendChild(cancelBtn);
|
||||
buttonContainer.appendChild(confirmBtn);
|
||||
|
||||
dialog.appendChild(messageDiv);
|
||||
dialog.appendChild(buttonContainer);
|
||||
|
||||
// Position near the anchor element
|
||||
document.body.appendChild(dialog);
|
||||
const rect = anchorElement.getBoundingClientRect();
|
||||
dialog.style.left = rect.left + 'px';
|
||||
dialog.style.top = (rect.bottom + 5) + 'px';
|
||||
|
||||
// Focus confirm button
|
||||
confirmBtn.focus();
|
||||
|
||||
return dialog;
|
||||
}
|
||||
|
||||
// Handle cell click for inline editing
|
||||
document.addEventListener('click', async (e) => {
|
||||
const cell = e.target.closest('td[data-editable="true"]');
|
||||
if (!cell) return;
|
||||
|
||||
// Don't trigger if clicking on a checkbox
|
||||
if (e.target.type === 'checkbox') return;
|
||||
|
||||
const fieldName = cell.dataset.field;
|
||||
const recordId = cell.dataset.recordId;
|
||||
const currentValue = cell.dataset.value;
|
||||
const colSettings = getColumnSettings(fieldName);
|
||||
|
||||
// Handle fields with choice dropdowns
|
||||
if (colSettings && colSettings.choices && colSettings.choices.length > 0) {
|
||||
await handleDropdownEdit(cell, fieldName, recordId, currentValue, colSettings);
|
||||
}
|
||||
// Handle date fields
|
||||
else if (['Due_Date', 'Start_Date', 'Submission_Date'].includes(fieldName)) {
|
||||
await handleDateEdit(cell, fieldName, recordId, currentValue);
|
||||
}
|
||||
// Handle text fields
|
||||
else {
|
||||
await handleTextEdit(cell, fieldName, recordId, currentValue);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle dropdown field editing
|
||||
async function handleDropdownEdit(cell, fieldName, recordId, currentValue, colSettings) {
|
||||
const choices = colSettings.choices.map(c => c.value);
|
||||
|
||||
// Create expanded list of choices
|
||||
const choiceContainer = document.createElement('div');
|
||||
choiceContainer.className = 'absolute bg-white border border-gray-300 rounded shadow-lg z-[1000] max-h-64 overflow-y-auto';
|
||||
choiceContainer.style.minWidth = '150px';
|
||||
|
||||
// Add empty option
|
||||
const emptyOption = document.createElement('div');
|
||||
emptyOption.className = 'px-3 py-2 hover:bg-blue-100 cursor-pointer text-sm ' + (currentValue === '' || !currentValue ? 'bg-blue-50 font-semibold' : '');
|
||||
emptyOption.textContent = '(None)';
|
||||
emptyOption.dataset.value = '';
|
||||
choiceContainer.appendChild(emptyOption);
|
||||
|
||||
// Add all choices
|
||||
choices.forEach(choice => {
|
||||
const option = document.createElement('div');
|
||||
option.className = 'px-3 py-2 hover:bg-blue-100 cursor-pointer text-sm ' + (choice === currentValue ? 'bg-blue-50 font-semibold' : '');
|
||||
option.textContent = choice;
|
||||
option.dataset.value = choice;
|
||||
choiceContainer.appendChild(option);
|
||||
});
|
||||
|
||||
// Position near the cell
|
||||
document.body.appendChild(choiceContainer);
|
||||
const rect = cell.getBoundingClientRect();
|
||||
choiceContainer.style.left = rect.left + 'px';
|
||||
choiceContainer.style.top = (rect.bottom + 2) + 'px';
|
||||
|
||||
// Handle choice selection
|
||||
const handleSelection = (newValue) => {
|
||||
choiceContainer.remove();
|
||||
|
||||
if (newValue !== currentValue) {
|
||||
createConfirmDialog(
|
||||
`Change ${fieldName} from "${currentValue || '(empty)'}" to "${newValue || '(empty)'}"?`,
|
||||
async () => {
|
||||
await updateField(recordId, fieldName, newValue || null);
|
||||
},
|
||||
() => {
|
||||
// Cancelled - do nothing
|
||||
},
|
||||
cell
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Add click handlers to all options
|
||||
choiceContainer.querySelectorAll('div').forEach(option => {
|
||||
option.addEventListener('click', () => {
|
||||
handleSelection(option.dataset.value);
|
||||
});
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
const closeOnOutsideClick = (e) => {
|
||||
if (!choiceContainer.contains(e.target) && !cell.contains(e.target)) {
|
||||
choiceContainer.remove();
|
||||
document.removeEventListener('click', closeOnOutsideClick);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', closeOnOutsideClick);
|
||||
}, 0);
|
||||
|
||||
// Close on escape
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
choiceContainer.remove();
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
|
||||
// Handle date field editing
|
||||
async function handleDateEdit(cell, fieldName, recordId, currentValue) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'date';
|
||||
input.value = currentValue || '';
|
||||
input.className = 'w-full px-2 py-1 border border-gray-300 rounded focus:outline-none focus:border-gray-400';
|
||||
input.style.position = 'relative';
|
||||
input.style.zIndex = '1000';
|
||||
|
||||
const originalHTML = cell.innerHTML;
|
||||
const originalPosition = cell.style.position;
|
||||
cell.style.position = 'relative';
|
||||
cell.innerHTML = '';
|
||||
cell.appendChild(input);
|
||||
input.focus();
|
||||
input.showPicker?.(); // Show date picker if supported
|
||||
|
||||
const finishEdit = async () => {
|
||||
const newValue = input.value.trim();
|
||||
cell.innerHTML = originalHTML;
|
||||
cell.style.position = originalPosition;
|
||||
|
||||
if (newValue !== currentValue) {
|
||||
const displayDate = newValue ? new Date(newValue + 'T00:00:00').toLocaleDateString() : '(empty)';
|
||||
|
||||
createConfirmDialog(
|
||||
`Change ${fieldName} to ${displayDate}?`,
|
||||
async () => {
|
||||
await updateField(recordId, fieldName, newValue || null);
|
||||
},
|
||||
() => {
|
||||
// Cancelled - do nothing
|
||||
},
|
||||
cell
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener('blur', finishEdit);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
finishEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
cell.innerHTML = originalHTML;
|
||||
cell.style.position = originalPosition;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle text field editing
|
||||
async function handleTextEdit(cell, fieldName, recordId, currentValue) {
|
||||
const isMultiline = fieldName === 'Notes';
|
||||
const input = document.createElement(isMultiline ? 'textarea' : 'input');
|
||||
|
||||
if (!isMultiline) {
|
||||
input.type = 'text';
|
||||
} else {
|
||||
input.rows = 3;
|
||||
}
|
||||
|
||||
input.value = currentValue || '';
|
||||
input.className = 'w-full px-2 py-1 border border-gray-300 rounded focus:outline-none focus:border-gray-400';
|
||||
input.style.position = 'relative';
|
||||
input.style.zIndex = '1000';
|
||||
|
||||
const originalHTML = cell.innerHTML;
|
||||
const originalPosition = cell.style.position;
|
||||
cell.style.position = 'relative';
|
||||
cell.innerHTML = '';
|
||||
cell.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const finishEdit = async () => {
|
||||
const newValue = input.value.trim();
|
||||
cell.innerHTML = originalHTML;
|
||||
cell.style.position = originalPosition;
|
||||
|
||||
if (newValue !== currentValue) {
|
||||
const preview = newValue.length > 50 ? newValue.substring(0, 50) + '...' : newValue;
|
||||
|
||||
createConfirmDialog(
|
||||
`Change ${fieldName} to "${preview || '(empty)'}"?`,
|
||||
async () => {
|
||||
await updateField(recordId, fieldName, newValue || null);
|
||||
},
|
||||
() => {
|
||||
// Cancelled - do nothing
|
||||
},
|
||||
cell
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener('blur', finishEdit);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !isMultiline) {
|
||||
e.preventDefault();
|
||||
finishEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
cell.innerHTML = originalHTML;
|
||||
cell.style.position = originalPosition;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update field in PocketBase
|
||||
async function updateField(recordId, fieldName, value) {
|
||||
try {
|
||||
await pb.collection('Job_Info_TestEnv').update(recordId, {
|
||||
[fieldName]: value
|
||||
});
|
||||
|
||||
// Reload table data to reflect changes
|
||||
await loadTableData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating field:', error);
|
||||
alert('Failed to update field. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
// Setup search input listener
|
||||
function setupSearchListener() {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
@@ -1068,24 +1556,58 @@
|
||||
if (isReadOnly && fieldName === 'Active') {
|
||||
// Revert the checkbox state
|
||||
checkbox.checked = !checkbox.checked;
|
||||
alert('Active status is calculated automatically by selected Job Status values.');
|
||||
|
||||
createConfirmDialog(
|
||||
'Active status is calculated automatically by selected Job Status values.',
|
||||
() => {},
|
||||
() => {},
|
||||
checkbox.closest('td')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update PocketBase for editable fields
|
||||
const newValue = checkbox.checked;
|
||||
const action = newValue ? 'check' : 'uncheck';
|
||||
|
||||
try {
|
||||
await pb.collection('Job_Info_TestEnv').update(recordId, {
|
||||
[fieldName]: newValue
|
||||
});
|
||||
// Note: Cache will auto-expire in 5 minutes, no need to clear immediately
|
||||
} catch (error) {
|
||||
console.error('Error updating field:', error);
|
||||
// Revert checkbox on error
|
||||
checkbox.checked = !checkbox.checked;
|
||||
alert('Failed to update field. Please try again.');
|
||||
}
|
||||
// Create confirmation dialog
|
||||
createConfirmDialog(
|
||||
`${action.charAt(0).toUpperCase() + action.slice(1)} ${fieldName}?`,
|
||||
async () => {
|
||||
// Confirmed - update field
|
||||
try {
|
||||
await pb.collection('Job_Info_TestEnv').update(recordId, {
|
||||
[fieldName]: newValue
|
||||
});
|
||||
|
||||
// Special reminder for Tax Exempt
|
||||
if (fieldName === 'Tax_Exempt' && newValue) {
|
||||
setTimeout(() => {
|
||||
createConfirmDialog(
|
||||
'Reminder: Please upload tax exempt documents.',
|
||||
() => {},
|
||||
() => {},
|
||||
checkbox.closest('td')
|
||||
);
|
||||
}, 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating field:', error);
|
||||
checkbox.checked = !checkbox.checked;
|
||||
createConfirmDialog(
|
||||
'Failed to update field. Please try again.',
|
||||
() => {},
|
||||
() => {},
|
||||
checkbox.closest('td')
|
||||
);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// Cancelled - revert checkbox
|
||||
checkbox.checked = !checkbox.checked;
|
||||
},
|
||||
checkbox.closest('td')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user