// Job CRUD operations let currentPage = 1; let itemsPerPage = 20; let allJobs = []; let jobsCollectionName = 'Job_Info'; // Default, will be loaded from config let configInitialized = false; let configInitializing = false; let currentView = localStorage.getItem('jobsView') || 'table'; // 'table' or 'card' /** * Load all jobs with optional filters */ async function loadJobs(page = 1) { try { // Show loading state // Show loading state based on current view if (currentView === 'table') { const tbody = document.getElementById('jobsTableBody'); if (tbody) { tbody.innerHTML = 'Loading...'; } } else { const container = document.getElementById('jobsCardContainer'); if (container) { container.innerHTML = '
Loading...
'; } } currentPage = page; const searchTerm = document.getElementById('searchInput')?.value || ''; const estimatorFilter = window.currentEstimatorFilter || 'all'; const statusFilter = window.currentStatusFilter || 'all'; // Build estimator filter string let estimatorFilterString = ''; if (estimatorFilter && estimatorFilter !== 'all') { // Escape special characters for PocketBase filter const escapedFilter = estimatorFilter.replace(/"/g, '\\"'); estimatorFilterString = `Estimator = "${escapedFilter}"`; } // Build status filter string let statusFilterString = ''; if (statusFilter && statusFilter !== 'all') { // Escape special characters for PocketBase filter const escapedStatus = statusFilter.replace(/"/g, '\\"'); statusFilterString = `Job_Status = "${escapedStatus}"`; } // Build search filter let searchFilter = ''; if (searchTerm) { // Escape special characters for PocketBase filter const escapedTerm = searchTerm.replace(/"/g, '\\"'); searchFilter = `Job_Number ~ "${escapedTerm}" || Job_Name ~ "${escapedTerm}" || Company_Client ~ "${escapedTerm}"`; } // Combine filters const filterParts = []; if (estimatorFilterString) filterParts.push(`(${estimatorFilterString})`); if (statusFilterString) filterParts.push(`(${statusFilterString})`); if (searchFilter) filterParts.push(`(${searchFilter})`); let finalFilter = ''; if (filterParts.length > 0) { finalFilter = filterParts.join(' && '); } const result = await pb.collection(jobsCollectionName).getList(page, itemsPerPage, { filter: finalFilter || undefined, sort: '-Job_Number', }); allJobs = result.items; displayJobs(allJobs); displayPagination(result.page, result.totalPages, result.totalItems); // Update view based on current view preference updateViewDisplay(); } catch (error) { console.error('Error loading jobs:', error); showMessage('Error loading jobs: ' + (error.message || 'Unknown error'), 'error'); if (currentView === 'table') { const tbody = document.getElementById('jobsTableBody'); if (tbody) { tbody.innerHTML = 'Error loading jobs. Please refresh the page.'; } } else { const container = document.getElementById('jobsCardContainer'); if (container) { container.innerHTML = '
Error loading jobs. Please refresh the page.
'; } } } } /** * Get estimator badge HTML */ function getEstimatorBadgeHtml(estimator) { if (!estimator) return '-'; const estimatorLower = estimator.toLowerCase(); let bgColor = 'bg-gray-100'; let textColor = 'text-gray-800'; if (estimatorLower.includes('steve')) { bgColor = 'bg-orange-100'; textColor = 'text-orange-800'; } else if (estimatorLower.includes('beth')) { bgColor = 'bg-blue-100'; textColor = 'text-blue-800'; } else if (estimatorLower.includes('timothy')) { bgColor = 'bg-purple-100'; textColor = 'text-purple-800'; } return `${estimator}`; } /** * Get status badge color */ function getStatusBadgeColor(status) { if (!status) return 'bg-gray-200 text-gray-700'; const statusLower = status.toLowerCase(); if (statusLower.includes('billed') && (statusLower.includes('closed') || statusLower.includes('progress'))) { return 'bg-purple-200 text-purple-800'; } else if (statusLower.includes('est hold')) { return 'bg-orange-200 text-orange-800'; } else if (statusLower.includes('est sent') || statusLower.includes('follow up')) { return 'bg-yellow-200 text-yellow-800'; } else if (statusLower.includes('estimating')) { return 'bg-blue-200 text-blue-800'; } else if (statusLower.includes('in progress') || statusLower.includes('awarded')) { return 'bg-green-200 text-green-800'; } else if (statusLower.includes('not awarded') || statusLower.includes('not bidding')) { return 'bg-red-200 text-red-800'; } else if (statusLower.includes('archive')) { return 'bg-gray-200 text-gray-700'; } return 'bg-gray-200 text-gray-700'; } /** * Display jobs in table or card view */ function displayJobs(jobs) { if (currentView === 'table') { displayJobsTable(jobs); } else { displayJobsCards(jobs); } } /** * Display jobs in table view */ function displayJobsTable(jobs) { const tbody = document.getElementById('jobsTableBody'); if (!tbody) return; if (!jobs || jobs.length === 0) { tbody.innerHTML = 'No jobs found'; return; } tbody.innerHTML = jobs.map(job => { const estimatorDisplay = getEstimatorBadgeHtml(job.Estimator); return ` ${job.Job_Number || '-'} ${job.Job_Name || '-'} ${job.Company_Client || '-'} ${estimatorDisplay} ${job.Job_Status || '-'} ${job.Job_Type || '-'} ${job.Job_Division || '-'} `; }).join(''); } /** * Display jobs in card view (multiple lines, single column) */ function displayJobsCards(jobs) { const container = document.getElementById('jobsCardContainer'); if (!container) return; if (!jobs || jobs.length === 0) { container.innerHTML = '
No jobs found
'; return; } container.innerHTML = jobs.map(job => { const estimatorBadge = getEstimatorBadgeHtml(job.Estimator); const statusColor = getStatusBadgeColor(job.Job_Status); return `

${job.Job_Number || 'N/A'}

${job.Job_Name || '-'}

Client

${job.Company_Client || '-'}

${job.Phone_Number ? `${job.Phone_Number}` : ''}
Estimator
${estimatorBadge}
Status
${job.Job_Status || '-'}
${job.Job_Address ? `
Address

${job.Job_Address}

` : ''}
Type

${job.Job_Type || '-'}

Division

${job.Job_Division || '-'}

`; }).join(''); } /** * Switch between table and card view */ function switchView(view) { currentView = view; localStorage.setItem('jobsView', view); updateViewDisplay(); displayJobs(allJobs); } /** * Update view display based on current view preference */ function updateViewDisplay() { const tableView = document.getElementById('tableView'); const cardView = document.getElementById('cardView'); const tableViewBtn = document.getElementById('tableViewBtn'); const cardViewBtn = document.getElementById('cardViewBtn'); if (currentView === 'table') { // Show table, hide card if (tableView) tableView.classList.remove('hidden'); if (cardView) cardView.classList.add('hidden'); // Table button: active (indigo with shadow) if (tableViewBtn) { tableViewBtn.classList.remove('text-gray-700', 'hover:bg-white/40'); tableViewBtn.classList.add('bg-indigo-600/90', 'text-white', 'shadow-lg', 'hover:bg-indigo-700/90'); } // Card button: inactive (transparent) if (cardViewBtn) { cardViewBtn.classList.remove('bg-indigo-600/90', 'text-white', 'shadow-lg', 'hover:bg-indigo-700/90'); cardViewBtn.classList.add('text-gray-700', 'hover:bg-white/40'); } } else { // Show card, hide table if (tableView) tableView.classList.add('hidden'); if (cardView) cardView.classList.remove('hidden'); // Table button: inactive (transparent) if (tableViewBtn) { tableViewBtn.classList.remove('bg-indigo-600/90', 'text-white', 'shadow-lg', 'hover:bg-indigo-700/90'); tableViewBtn.classList.add('text-gray-700', 'hover:bg-white/40'); } // Card button: active (indigo with shadow) if (cardViewBtn) { cardViewBtn.classList.remove('text-gray-700', 'hover:bg-white/40'); cardViewBtn.classList.add('bg-indigo-600/90', 'text-white', 'shadow-lg', 'hover:bg-indigo-700/90'); } } } // Make functions globally accessible window.switchView = switchView; window.updateViewDisplay = updateViewDisplay; /** * Display pagination controls */ function displayPagination(currentPage, totalPages, totalItems) { const container = document.getElementById('paginationContainer'); if (!container) return; if (totalPages <= 1) { container.innerHTML = ''; return; } let html = '
'; // Previous button if (currentPage > 1) { html += ``; } // Page info html += `Page ${currentPage} of ${totalPages} (${totalItems} total)`; // Next button if (currentPage < totalPages) { html += ``; } html += '
'; container.innerHTML = html; } /** * Load job for editing */ async function loadJobForEdit(jobId) { try { if (!jobId) { showMessage('No job ID provided', 'error'); return null; } const job = await pb.collection(jobsCollectionName).getOne(jobId); // Update dashboard header and quick info if (typeof updateDashboardInfo === 'function') { updateDashboardInfo(job); } // Populate form fields Object.keys(job).forEach(key => { const field = document.getElementById(key); if (field) { if (field.type === 'checkbox') { field.checked = job[key] === true; } else if (field.type === 'date') { if (job[key]) { try { const date = new Date(job[key]); if (!isNaN(date.getTime())) { field.value = date.toISOString().split('T')[0]; } } catch (e) { console.warn('Invalid date for field', key, job[key]); } } } else if (field.type === 'time') { if (job[key]) { field.value = job[key]; } } else { field.value = job[key] || ''; } } }); return job; } catch (error) { console.error('Error loading job:', error); showMessage('Error loading job: ' + (error.message || 'Unknown error'), 'error'); return null; } } /** * Save job (create or update) */ async function saveJob() { try { const form = document.getElementById('jobForm'); if (!form) { showMessage('Form not found', 'error'); return; } const formData = new FormData(form); const jobId = document.getElementById('jobId')?.value; // Build job object const jobData = {}; const booleanFields = ['Active', 'Flag', 'Tax_Exempt', 'Docs_Uploaded', 'Tax_Exempt_Docs_Uploaded', 'Estimate_Approved', 'Estimate_Reviewed', 'Estimate_Draft', 'QB_Created', 'Added_To_Calendar', 'PSwift_Uploaded', 'Voxer_Created']; // Get all form fields for (const [key, value] of formData.entries()) { if (key === 'id') continue; if (booleanFields.includes(key)) { const checkbox = document.getElementById(key); jobData[key] = checkbox ? checkbox.checked : false; } else if (value && value.trim() !== '') { jobData[key] = value.trim(); } } if (jobId) { // Update existing job await pb.collection(jobsCollectionName).update(jobId, jobData); showMessage('Job updated successfully', 'success'); } else { // Create new job await pb.collection(jobsCollectionName).create(jobData); showMessage('Job created successfully', 'success'); } if (typeof closeJobModal === 'function') { closeJobModal(); } loadJobs(currentPage); } catch (error) { console.error('Error saving job:', error); const errorMsg = error.message || 'Unknown error'; showMessage('Error saving job: ' + errorMsg, 'error'); } } /** * Delete job */ async function deleteJob(jobId) { if (!confirm('Are you sure you want to delete this job?')) { return; } try { await pb.collection(jobsCollectionName).delete(jobId); showMessage('Job deleted successfully', 'success'); loadJobs(currentPage); } catch (error) { console.error('Error deleting job:', error); showMessage('Error deleting job: ' + (error.message || 'Unknown error'), 'error'); } } /** * Get job statistics for dashboard */ async function getJobStats() { try { // Ensure config is loaded before getting stats (only once) if (typeof initConfig !== 'undefined') { await initConfig(); } const collectionName = jobsCollectionName || 'Job_Info'; // Fallback to default // Make requests sequentially to avoid auto-cancellation const totalResult = await pb.collection(collectionName).getList(1, 1); const activeResult = await pb.collection(collectionName).getList(1, 1, { filter: 'Active = true' }); const estimatingResult = await pb.collection(collectionName).getList(1, 1, { filter: 'Job_Status = "Estimating"' }); const estSentResult = await pb.collection(collectionName).getList(1, 1, { filter: 'Job_Status = "Est Sent"' }); const estHoldResult = await pb.collection(collectionName).getList(1, 1, { filter: 'Job_Status = "Est Hold"' }); const followUpResult = await pb.collection(collectionName).getList(1, 1, { filter: 'Job_Status = "Follow Up"' }); const awardedResult = await pb.collection(collectionName).getList(1, 1, { filter: 'Job_Status = "Awarded"' }); const inProgressResult = await pb.collection(collectionName).getList(1, 1, { filter: 'Job_Status = "In Progress"' }); const billedInProgressResult = await pb.collection(collectionName).getList(1, 1, { filter: 'Job_Status = "Billed (In Progress)"' }); const billedClosedResult = await pb.collection(collectionName).getList(1, 1, { filter: 'Job_Status = "Billed (Closed)"' }); const notAwardedResult = await pb.collection(collectionName).getList(1, 1, { filter: 'Job_Status = "Not Awarded"' }); const notBiddingResult = await pb.collection(collectionName).getList(1, 1, { filter: 'Job_Status = "Not Bidding"' }); const archiveResult = await pb.collection(collectionName).getList(1, 1, { filter: 'Job_Status = "Archive"' }); const stats = { total: totalResult.totalItems || 0, active: activeResult.totalItems || 0, estimating: estimatingResult.totalItems || 0, estSent: estSentResult.totalItems || 0, estHold: estHoldResult.totalItems || 0, followUp: followUpResult.totalItems || 0, awarded: awardedResult.totalItems || 0, inProgress: inProgressResult.totalItems || 0, billedInProgress: billedInProgressResult.totalItems || 0, billedClosed: billedClosedResult.totalItems || 0, notAwarded: notAwardedResult.totalItems || 0, notBidding: notBiddingResult.totalItems || 0, archive: archiveResult.totalItems || 0 }; return stats; } catch (error) { console.error('Error getting stats:', error); return { total: 0, active: 0, estimating: 0, estSent: 0, estHold: 0, followUp: 0, awarded: 0, inProgress: 0, billedInProgress: 0, billedClosed: 0, notAwarded: 0, notBidding: 0, archive: 0 }; } } /** * Load jobs for select dropdown */ async function loadJobsForSelect() { try { const result = await pb.collection(jobsCollectionName).getList(1, 1000, { sort: 'Job_Number' }); const select = document.getElementById('noteJob'); if (!select) return; select.innerHTML = ''; result.items.forEach(job => { const option = document.createElement('option'); option.value = job.id; option.textContent = `${job.Job_Number || job.id} - ${job.Job_Name || 'Unnamed'}`; select.appendChild(option); }); } catch (error) { console.error('Error loading jobs for select:', error); } } /** * Load unique estimators and create filter buttons */ async function loadEstimatorFilters() { try { // Ensure config is loaded await initConfig(); // Get all jobs to extract unique estimators const result = await pb.collection(jobsCollectionName).getList(1, 1000, { fields: 'Estimator' }); // Extract unique estimators const estimators = new Set(); result.items.forEach(job => { if (job.Estimator && job.Estimator.trim() !== '') { estimators.add(job.Estimator.trim()); } }); // Sort estimators alphabetically const sortedEstimators = Array.from(estimators).sort(); // Get filter buttons container const container = document.getElementById('filterButtons'); if (!container) return; // Keep the "All" button and add estimator buttons const allButton = container.querySelector('#filterAll'); container.innerHTML = ''; if (allButton) { container.appendChild(allButton); } // Add estimator filter buttons with pastel colors sortedEstimators.forEach(estimator => { const button = document.createElement('button'); button.onclick = () => { if (typeof window.setEstimatorFilter === 'function') { window.setEstimatorFilter(estimator); } }; button.id = `filter-${estimator.replace(/\s+/g, '-')}`; // Set pastel colors for specific estimators let bgColor = 'bg-gray-200'; let textColor = 'text-gray-700'; let hoverColor = 'hover:bg-gray-300'; const estimatorLower = estimator.toLowerCase(); if (estimatorLower.includes('steve')) { bgColor = 'bg-orange-200'; textColor = 'text-orange-800'; hoverColor = 'hover:bg-orange-300'; } else if (estimatorLower.includes('beth')) { bgColor = 'bg-blue-200'; textColor = 'text-blue-800'; hoverColor = 'hover:bg-blue-300'; } else if (estimatorLower.includes('timothy')) { bgColor = 'bg-purple-200'; textColor = 'text-purple-800'; hoverColor = 'hover:bg-purple-300'; } button.className = `flex-1 sm:flex-none px-3 py-1.5 rounded-md text-xs font-medium transition focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1 ${bgColor} ${textColor} ${hoverColor}`; button.textContent = estimator; container.appendChild(button); }); } catch (error) { console.error('Error loading estimator filters:', error); } } /** * Load unique job statuses and create filter buttons */ async function loadStatusFilters() { try { // Ensure config is loaded await initConfig(); // Get all jobs to extract unique statuses const result = await pb.collection(jobsCollectionName).getList(1, 1000, { fields: 'Job_Status' }); // Extract unique statuses const statuses = new Set(); result.items.forEach(job => { if (job.Job_Status && job.Job_Status.trim() !== '') { statuses.add(job.Job_Status.trim()); } }); // Define the desired order const statusOrder = [ 'Estimating', 'Est Sent', 'Est Hold', 'Follow Up', 'Awarded', 'In Progress', 'Billed (In Progress)', 'Billed (Closed)', 'Not Awarded', 'Not Bidding', 'Archive' ]; // Sort statuses by the defined order, then alphabetically for any not in the list const sortedStatuses = Array.from(statuses).sort((a, b) => { const indexA = statusOrder.findIndex(order => a.toLowerCase() === order.toLowerCase() || a.toLowerCase().includes(order.toLowerCase()) || order.toLowerCase().includes(a.toLowerCase()) ); const indexB = statusOrder.findIndex(order => b.toLowerCase() === order.toLowerCase() || b.toLowerCase().includes(order.toLowerCase()) || order.toLowerCase().includes(b.toLowerCase()) ); // If both are in the order list, sort by their position if (indexA !== -1 && indexB !== -1) { return indexA - indexB; } // If only A is in the list, A comes first if (indexA !== -1) return -1; // If only B is in the list, B comes first if (indexB !== -1) return 1; // If neither is in the list, sort alphabetically return a.localeCompare(b); }); // Get status filter buttons container const container = document.getElementById('statusFilterButtons'); if (!container) return; // Keep the "All" button and add status buttons const allButton = container.querySelector('#statusFilterAll'); container.innerHTML = ''; if (allButton) { container.appendChild(allButton); } // Create buttons for each status with specific colors sortedStatuses.forEach(status => { const button = document.createElement('button'); button.textContent = status; button.onclick = () => { if (typeof window.setStatusFilter === 'function') { window.setStatusFilter(status); } }; // Set colors based on status let bgColor = 'bg-gray-200'; let textColor = 'text-gray-700'; let hoverColor = 'hover:bg-gray-300'; const statusLower = status.toLowerCase(); // Purple for Billed statuses if (statusLower.includes('billed') && statusLower.includes('closed')) { bgColor = 'bg-purple-200'; textColor = 'text-purple-800'; hoverColor = 'hover:bg-purple-300'; } else if (statusLower.includes('billed') && statusLower.includes('progress')) { bgColor = 'bg-purple-200'; textColor = 'text-purple-800'; hoverColor = 'hover:bg-purple-300'; } // Orange for Est Hold else if (statusLower.includes('est hold') || statusLower === 'est hold') { bgColor = 'bg-orange-200'; textColor = 'text-orange-800'; hoverColor = 'hover:bg-orange-300'; } // Yellow for Est Sent and Follow Up else if (statusLower.includes('est sent') || statusLower === 'est sent' || statusLower.includes('follow up') || statusLower === 'follow up') { bgColor = 'bg-yellow-200'; textColor = 'text-yellow-800'; hoverColor = 'hover:bg-yellow-300'; } // Blue for Estimating else if (statusLower.includes('estimating') || statusLower === 'estimating') { bgColor = 'bg-blue-200'; textColor = 'text-blue-800'; hoverColor = 'hover:bg-blue-300'; } // Green for In Progress and Awarded else if (statusLower.includes('in progress') || statusLower === 'in progress' || statusLower.includes('awarded') || statusLower === 'awarded') { bgColor = 'bg-green-200'; textColor = 'text-green-800'; hoverColor = 'hover:bg-green-300'; } // Red for Not Awarded and Not Bidding else if (statusLower.includes('not awarded') || statusLower === 'not awarded' || statusLower.includes('not bidding') || statusLower === 'not bidding') { bgColor = 'bg-red-200'; textColor = 'text-red-800'; hoverColor = 'hover:bg-red-300'; } // Gray for Archive else if (statusLower.includes('archive') || statusLower === 'archive') { bgColor = 'bg-gray-200'; textColor = 'text-gray-700'; hoverColor = 'hover:bg-gray-300'; } button.className = `flex-1 sm:flex-none px-3 py-1.5 rounded-md text-xs font-medium transition focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1 ${bgColor} ${textColor} ${hoverColor}`; container.appendChild(button); }); } catch (error) { console.error('Error loading status filters:', error); } } /** * Show message to user */ function showMessage(message, type = 'success') { const container = document.getElementById('messageContainer'); if (!container) return; const bgColor = type === 'success' ? 'bg-green-50 border-green-200 text-green-700' : 'bg-red-50 border-red-200 text-red-700'; container.innerHTML = `
${message}
`; // Auto-hide after 5 seconds setTimeout(() => { container.innerHTML = ''; }, 5000); } /** * Initialize configuration (idempotent - only runs once) */ async function initConfig() { // If already initialized, return immediately if (configInitialized) { return; } // If currently initializing, wait for it to complete if (configInitializing) { while (configInitializing) { await new Promise(resolve => setTimeout(resolve, 50)); } return; } // Mark as initializing configInitializing = true; try { if (typeof getJobsCollection === 'undefined') { console.warn('getJobsCollection not available, using default collection name'); jobsCollectionName = 'Job_Info'; configInitialized = true; configInitializing = false; return; } const collectionName = await getJobsCollection(); jobsCollectionName = collectionName; console.log('Config initialized, jobs collection:', jobsCollectionName); if (typeof getItemsPerPage !== 'undefined') { const itemsPerPageConfig = await getItemsPerPage(); itemsPerPage = itemsPerPageConfig; } configInitialized = true; } catch (error) { console.error('Error initializing config:', error); // Use defaults if config fails to load jobsCollectionName = jobsCollectionName || 'Job_Info'; configInitialized = true; } finally { configInitializing = false; } } // Initialize config when script loads (but don't block) // Note: This will be called, but subsequent calls to initConfig() will be idempotent if (typeof getJobsCollection !== 'undefined') { initConfig().catch(err => console.error('Config initialization error:', err)); }