Files
Job-List/estimatortable.html
T
aewing ad4a4fdf4b Update to v1.0.0-alpha5
- Add hover tooltip column setting for truncated text
- Update Tax_Exempt_Docs_Uploaded label to 'Tax Exempt Uploads?'
2026-01-02 04:18:42 +00:00

1631 lines
62 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Estimator 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 {
position: relative;
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;
}
.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;
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);
padding: 12px 16px;
}
/* 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 {
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 */
.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">
<!-- 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">
<!-- Top-right display name and settings -->
<div id="userDisplay" class="hidden absolute top-4 right-4 flex items-center space-x-3" style="z-index: 1000;">
<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>
<button id="logoutBtn" class="text-white hover:text-red-300 transition-colors" title="Logout">
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
<!-- Header Section -->
<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>
<!-- Search Section -->
<div class="bg-white rounded-lg shadow-2xl px-4 py-2 mt-2">
<div class="flex justify-end">
<div style="width: 400px;">
<div class="relative">
<input
type="text"
id="searchInput"
placeholder="Search jobs..."
class="w-full px-4 py-1.5 pl-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<svg class="absolute left-3 top-2 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<span id="searchResultCount" class="absolute right-3 top-2 text-sm text-gray-600"></span>
</div>
</div>
</div>
</div>
<!-- Table Section -->
<div class="bg-white rounded-lg shadow-2xl p-4 mt-2" 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>
<!-- Footer -->
<div class="mt-2 flex justify-between items-center text-white text-xs px-4">
<span id="footerEmail"></span>
<span>v1.0.0-alpha5</span>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/pocketbase/dist/pocketbase.umd.js"></script>
<script>
// Suppress QUIC protocol errors (cosmetic browser issue with PocketBase realtime)
const originalConsoleError = console.error;
console.error = function(...args) {
const message = args.join(' ');
if (message.includes('ERR_QUIC_PROTOCOL_ERROR') || message.includes('/api/realtime')) {
return; // Suppress these specific errors
}
originalConsoleError.apply(console, args);
};
// Initialize PocketBase client
const pb = new PocketBase('https://pocketbase.ccllc.pro');
// Column name to position mapping fallback (when no settings found)
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;
let appSettingsRecordId = null;
let appSettingsUnsubscribe = null;
let tableReloadTimer = null;
let isLoadingTableData = false;
let allRecords = []; // Store all records for filtering
let currentSearchTerm = '';
function normalizeColumnOrders(columns) {
if (!Array.isArray(columns)) return [];
const normalized = columns.map((col, idx) => {
const orderNum = Number.parseInt(col?.order, 10);
const safeOrder = Number.isFinite(orderNum) ? orderNum : idx + 1;
return { ...col, order: safeOrder };
}).sort((a, b) => a.order - b.order);
return normalized;
}
// Helpers to get columns in current order/visibility
function getVisibleColumns() {
if (appSettings && Array.isArray(appSettings.columns) && appSettings.columns.length) {
return normalizeColumnOrders(appSettings.columns).filter((c) => c.visible !== false);
}
// Fallback to default order when no settings yet
return COLUMN_ORDER.map((name, idx) => ({ name, visible: true, order: idx + 1 }));
}
function prettyLabel(name) {
// Special case for Tax_Exempt_Docs_Uploaded
if (name === 'Tax_Exempt_Docs_Uploaded') {
return 'Tax Exempt Uploads?';
}
return name.replace(/_/g, ' ');
}
function scheduleTableReload() {
if (tableReloadTimer) {
clearTimeout(tableReloadTimer);
}
tableReloadTimer = setTimeout(() => {
if (isLoadingTableData) {
return;
}
loadTableData();
}, 200);
}
// Load app settings from PocketBase (global settings)
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;
appSettingsRecordId = records[0].id;
console.log('Settings record ID:', appSettingsRecordId);
if (settingsData) {
appSettings = typeof settingsData === 'string' ? JSON.parse(settingsData) : settingsData;
// Ensure all columns have order field
if (appSettings && Array.isArray(appSettings.columns)) {
const needsOrderSync = appSettings.columns.some(col => col.order === undefined || col.order === null);
if (needsOrderSync) {
console.log('Columns missing order field, syncing...');
appSettings.columns = appSettings.columns.map((col, idx) => ({
...col,
order: col.order ?? idx + 1
}));
}
appSettings.columns = normalizeColumnOrders(appSettings.columns);
}
console.log('Loaded app settings:', appSettings);
applyGlobalSettings();
renderTableHeader();
} else {
console.log('No settings data in record');
}
} else {
console.log('No settings record found, using defaults');
renderTableHeader();
}
} catch (error) {
console.error('Error loading app settings:', error);
renderTableHeader(); // Still render with defaults
}
}
// Watch for live updates to app settings
async function startSettingsWatch() {
// Avoid duplicate subscriptions
if (appSettingsUnsubscribe) return;
try {
appSettingsUnsubscribe = await pb.collection('app_preferences_settings').subscribe('*', (e) => {
const record = e?.record;
if (!record) return;
if (record.app_name !== APP_NAME) return;
// If we didn't have the record id yet, capture it
if (!appSettingsRecordId) appSettingsRecordId = record.id;
// Only respond to the tracked settings record if known
if (appSettingsRecordId && record.id !== appSettingsRecordId) return;
const settingsData = record.app_json_prefs;
appSettings = typeof settingsData === 'string' ? JSON.parse(settingsData || '{}') : (settingsData || {});
if (appSettings && Array.isArray(appSettings.columns)) {
const needsOrderSync = appSettings.columns.some(col => col.order === undefined || col.order === null);
if (needsOrderSync) {
console.log('Realtime update: columns missing order field, syncing...');
appSettings.columns = appSettings.columns.map((col, idx) => ({
...col,
order: col.order ?? idx + 1
}));
}
console.log('Received settings update via realtime, original orders:', appSettings.columns.map(c => ({ name: c.name, order: c.order })).slice(0, 5));
appSettings.columns = normalizeColumnOrders(appSettings.columns);
console.log('After normalize/sort:', appSettings.columns.map(c => ({ name: c.name, order: c.order })).slice(0, 5));
}
console.log('App settings updated via realtime', { action: e.action });
applyGlobalSettings();
scheduleTableReload();
}, { /* Silently handle connection errors */ });
} catch (err) {
// Silently ignore realtime connection errors (QUIC protocol issues are cosmetic)
}
}
// Render table header dynamically based on current settings
function renderTableHeader() {
const headerRow = document.querySelector('.data-table thead tr');
if (!headerRow) {
console.error('Header row not found');
return;
}
const visibleColumns = getVisibleColumns();
console.log('Rendering header with', visibleColumns.length, 'columns');
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)
function applyGlobalSettings() {
// Remove any existing dynamic styles first
const existingStyle = document.getElementById('dynamic-column-styles');
if (existingStyle) existingStyle.remove();
const visibleColumns = getVisibleColumns();
let styleContent = '';
// Apply row height
if (appSettings && appSettings.rowHeight) {
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
if (appSettings && appSettings.fontSize) {
styleContent += `.data-table { font-size: ${appSettings.fontSize}px; }\n`;
}
// Apply striped rows setting
if (appSettings && appSettings.stripedRows === false) {
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;
const col2Width = visibleColumns[1]?.width || 250;
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;
}
`;
if (visibleColumns.length > 1) {
styleContent += `
.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;
}
`;
}
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}),
.data-table td:nth-child(${idx + 1}) {
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 += `
.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);
// Ensure header reflects current order/visibility
renderTableHeader();
// Re-render body only if data is already loaded
if (allRecords && allRecords.length > 0) {
renderTableRows();
}
// Note: Don't call scheduleTableReload here - it will be called after initial load
}
// 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
console.log('User authenticated, loading...');
loadingIndicator.classList.remove('hidden');
// If model is missing, try to get current user
if (!pb.authStore.model) {
try {
const user = await pb.collection('users').getOne(pb.authStore.token.split('.')[1] ? JSON.parse(atob(pb.authStore.token.split('.')[1])).id : '');
pb.authStore.save(pb.authStore.token, user);
console.log('Loaded user model:', user);
} catch (err) {
console.error('Failed to load user model:', err);
}
}
await loadAppSettings();
console.log('Settings loaded');
await startSettingsWatch();
console.log('Settings watch started');
showTable();
console.log('Table shown');
await loadTableData();
console.log('Data loaded');
} else {
// Need to login
console.log('User not authenticated');
loginContainer.classList.remove('hidden');
}
}
// Login with Microsoft
loginBtn.addEventListener('click', async () => {
try {
loginBtn.disabled = true;
loginBtn.textContent = 'Logging in...';
loadingIndicator.classList.remove('hidden');
const authData = await pb.collection('users').authWithOAuth2({ provider: 'microsoft' });
console.log('Login successful, authData:', authData);
console.log('Auth record:', authData.record);
// Ensure the model is properly saved
if (authData.record) {
pb.authStore.save(pb.authStore.token, authData.record);
console.log('Saved user model to authStore:', authData.record);
}
await loadAppSettings();
await startSettingsWatch();
showTable();
await loadTableData();
} catch (error) {
console.error('Login failed:', error);
alert('Login failed. Please try again.');
loginBtn.disabled = false;
loginBtn.textContent = 'Login with Microsoft';
loadingIndicator.classList.add('hidden');
}
});
function showTable() {
loginContainer.classList.add('hidden');
tableContainer.classList.remove('hidden');
console.log('showTable() called - pb.authStore:', pb.authStore);
console.log('showTable() - pb.authStore.model:', pb.authStore.model);
console.log('showTable() - pb.authStore.isValid:', pb.authStore.isValid);
if (pb.authStore.model) {
const displayName = pb.authStore.model.name || pb.authStore.model.email || 'User';
const email = pb.authStore.model.email || '';
console.log('USER INFO - Display Name:', displayName, '| Email:', email);
userDisplay.classList.remove('hidden');
userDisplayName.textContent = displayName;
// Set footer email
const footerEmail = document.getElementById('footerEmail');
if (footerEmail) {
footerEmail.textContent = email;
}
} else {
console.log('ERROR: pb.authStore.model is null/undefined!');
}
// Setup search listener after table is shown
setupSearchListener();
}
// Settings button handler
document.addEventListener('click', (e) => {
if (e.target.closest('#settingsBtn')) {
window.location.href = '/appsettings.html';
}
if (e.target.closest('#logoutBtn')) {
pb.authStore.clear();
window.location.reload();
}
});
// 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>`;
}
// Per-column classes to keep layout/styling consistent when order changes
const columnClassMap = {
Job_Number: 'px-4 py-3 border-r border-gray-200 whitespace-nowrap font-medium',
Job_Full_Name: 'px-4 py-3 border-r border-gray-200 whitespace-nowrap',
Active: 'px-4 py-3 text-center',
Added_To_Calendar: 'px-4 py-3 text-center',
Docs_Uploaded: 'px-4 py-3 text-center',
Due_Date: 'px-4 py-3 whitespace-nowrap',
Estimate_Approved: 'px-4 py-3 text-center',
Estimate_Draft: 'px-4 py-3 text-center',
Estimate_Reviewed: 'px-4 py-3 text-center',
Flag: 'px-4 py-3 text-center',
Has_Attachments: 'px-4 py-3 text-center',
Notes: 'px-4 py-3 max-w-xs truncate',
PSwift_Uploaded: 'px-4 py-3 text-center',
QB_Created: 'px-4 py-3 text-center',
Start_Date: 'px-4 py-3 whitespace-nowrap',
Submission_Date: 'px-4 py-3 whitespace-nowrap',
Tax_Exempt: 'px-4 py-3 text-center',
Tax_Exempt_Docs_Uploaded: 'px-4 py-3 text-center',
Voxer_Created: 'px-4 py-3 text-center',
last_note_at: 'px-4 py-3 whitespace-nowrap',
latest_note_summary: 'px-4 py-3 max-w-xs truncate',
note_count: 'px-4 py-3 text-center',
note_thread_text: 'px-4 py-3 max-w-xs truncate'
};
// Render cell content for each supported column
function renderCellContent(columnName, record, value) {
switch (columnName) {
case 'Active':
return formatActiveCheckbox(record.Active, record.id);
case 'Added_To_Calendar':
return formatEditableCheckbox(record.Added_To_Calendar, 'Added_To_Calendar', record.id);
case 'Docs_Uploaded':
return formatEditableCheckbox(record.Docs_Uploaded, 'Docs_Uploaded', record.id);
case 'Due_Date':
return formatDate(record.Due_Date, record.Due_Date_Notes);
case 'Due_Date_Counter':
return calculateDueDateCounter(record);
case 'Estimate_Approved':
return formatEditableCheckbox(record.Estimate_Approved, 'Estimate_Approved', record.id);
case 'Estimate_Draft':
return formatEditableCheckbox(record.Estimate_Draft, 'Estimate_Draft', record.id);
case 'Estimate_Reviewed':
return formatEditableCheckbox(record.Estimate_Reviewed, 'Estimate_Reviewed', record.id);
case 'Flag':
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':
return formatEditableCheckbox(record.PSwift_Uploaded, 'PSwift_Uploaded', record.id);
case 'QB_Created':
return formatEditableCheckbox(record.QB_Created, 'QB_Created', record.id);
case 'Start_Date':
return formatDate(record.Start_Date, record.Start_Date_Notes);
case 'Submission_Date':
return formatDate(record.Submission_Date);
case 'Tax_Exempt':
return formatEditableCheckbox(record.Tax_Exempt, 'Tax_Exempt', record.id);
case 'Tax_Exempt_Docs_Uploaded':
return formatEditableCheckbox(record.Tax_Exempt_Docs_Uploaded, 'Tax_Exempt_Docs_Uploaded', record.id);
case 'Voxer_Created':
return formatEditableCheckbox(record.Voxer_Created, 'Voxer_Created', record.id);
case 'last_note_at':
return formatDateTime(record.last_note_at);
default:
return value || '';
}
}
// 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 || '';
case 'latest_note_summary':
return record.latest_note_summary || '';
case 'note_thread_text':
return record.note_thread_text || '';
default:
return '';
}
}
// Load table data from Redis cache (via API endpoint)
async function loadTableData() {
try {
if (isLoadingTableData) {
console.log('Table data already loading, skipping');
return;
}
isLoadingTableData = true;
loadingIndicator.classList.remove('hidden');
// Fetch from cached API endpoint
const response = await fetch('/api/jobs', {
headers: {
'X-PocketBase-Token': pb.authStore.token
}
});
if (!response.ok) {
throw new Error('Failed to fetch jobs from API');
}
const result = await response.json();
if (!result.success) {
throw new Error(result.message || 'Failed to fetch jobs');
}
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]);
// Render with current search filter
renderTableRows();
console.log('Render complete');
isLoadingTableData = false;
loadingIndicator.classList.add('hidden');
} catch (error) {
console.error('Error loading table data:', error);
isLoadingTableData = false;
loadingIndicator.classList.add('hidden');
alert('Error loading data. Please refresh the page.');
}
}
// Render table rows with optional filtering
function renderTableRows() {
if (!tableBody) {
console.error('tableBody element not found');
return;
}
if (!allRecords || allRecords.length === 0) {
console.log('No records to render');
tableBody.innerHTML = '<tr><td colspan="50" class="px-4 py-8 text-center text-gray-500">No data available</td></tr>';
return;
}
tableBody.innerHTML = '';
const visibleColumns = getVisibleColumns();
// Filter records based on search term
const filteredRecords = filterRecords(allRecords, currentSearchTerm);
// Update search result count
const searchResultCount = document.getElementById('searchResultCount');
if (searchResultCount) {
if (currentSearchTerm.trim()) {
searchResultCount.textContent = `${filteredRecords.length} of ${allRecords.length}`;
} else {
searchResultCount.textContent = '';
}
}
if (filteredRecords.length === 0) {
tableBody.innerHTML = '<tr><td colspan="50" class="px-4 py-8 text-center text-gray-500">No matching results</td></tr>';
return;
}
filteredRecords.forEach((record, recordIndex) => {
const row = document.createElement('tr');
row.className = 'border-b border-gray-200';
const rowIndex = Array.from(tableBody.children).length;
const cellsHtml = visibleColumns.map((col, idx) => {
const columnName = col.name;
const value = record[columnName];
const titleValue = getCellTitle(columnName, record);
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) {
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;';
}
}
}
// 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);
// 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, '&quot;');
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);
}
});
}
// Filter records based on search term
function filterRecords(records, searchTerm) {
if (!searchTerm || !searchTerm.trim()) {
return records;
}
const term = searchTerm.toLowerCase().trim();
const searchFields = [
'Job_Number',
'Job_Full_Name',
'Job_Address',
'Estimator',
'Office_Rep',
'Project_Manager',
'Company_Client',
'Contact_Person',
'Notes',
'latest_note_summary',
'note_thread_text'
];
return records.filter(record => {
return searchFields.some(field => {
const value = record[field];
if (!value) return false;
return String(value).toLowerCase().includes(term);
});
});
}
// 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');
if (!searchInput) return;
let searchTimeout;
searchInput.addEventListener('input', (e) => {
// Clear existing timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
}
// Debounce search to avoid re-rendering on every keystroke
searchTimeout = setTimeout(() => {
currentSearchTerm = e.target.value;
renderTableRows();
}, 150); // 150ms debounce - feels instant but avoids excessive renders
});
}
// 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;
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';
// 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')
);
}
});
// Initialize
checkAuth();
// Clean up subscription on unload
window.addEventListener('beforeunload', () => {
try {
if (appSettingsUnsubscribe) {
appSettingsUnsubscribe();
appSettingsUnsubscribe = null;
}
} catch (err) {
console.warn('Error while unsubscribing settings watch', err);
}
});
</script>
</body>
</html>