// PERMANENT: Home view - Jobs listing with zero-delay loading via IndexedDB cache // Wired to tokenService via backend API routes // Visual match to Mode1Test but uses new architecture // // Dependencies: // - Backend /api/auth/status for authentication check // - Backend /api/jobs for job data // - job-cache.ts for IndexedDB caching // // Features: // - Zero-delay load (displays cached jobs instantly) // - Background refresh after displaying cache // - Search with voice support (operates on cached data) // - Filters (division, active, estimator, status) // - Pagination // - Click job to open Details view import { jobCache, type JobCard } from './job-cache.js'; interface Job { Job_Number: string; Job_Name?: string; Job_Full_Name?: string; Job_Address?: string; Job_Division?: string; Active?: boolean | string; Status?: string; Job_Status?: string; Estimator?: string; Contact_Person?: string; Company_Client?: string; [key: string]: any; } let allJobs: Job[] = []; let filteredJobs: Job[] = []; let currentPage = 1; const perPage = 50; let isRefreshing = false; // Check authentication on page load async function checkAuth(): Promise { try { const response = await fetch('/api/auth/status'); const data = await response.json(); if (!data.authenticated) { // Redirect to login window.location.href = '/signin.html'; return false; } return true; } catch (err) { console.error('Auth check failed:', err); window.location.href = '/signin.html'; return false; } } // Fetch jobs from backend (or cache) async function fetchJobs(): Promise { try { // Initialize cache await jobCache.initialize(); // Check if cache is valid const isCacheValid = await jobCache.isCacheValid(); if (isCacheValid) { // Display cached jobs immediately (zero delay) const cachedJobs = await jobCache.getCachedJobs(); allJobs = cachedJobs; filteredJobs = [...allJobs]; renderJobs(); // Show cache age in console for debugging const cacheAge = await jobCache.getCacheAge(); console.log(`Loaded ${allJobs.length} jobs from cache (age: ${Math.round((cacheAge || 0) / 1000)}s)`); // Refresh in background (don't block UI) refreshJobsInBackground(); } else { // No valid cache, fetch fresh data (no progress bar) const jobs = await jobCache.refreshJobs(); allJobs = jobs; filteredJobs = [...allJobs]; renderJobs(); console.log(`Loaded ${allJobs.length} jobs from server (cache updated)`); } } catch (err) { console.error('Failed to fetch jobs:', err); // Show error message const results = document.getElementById('results') as HTMLDivElement; results.innerHTML = '
Failed to load jobs. Please refresh the page.
'; } } // Background refresh (silent, doesn't interrupt user) async function refreshJobsInBackground(): Promise { if (isRefreshing) return; isRefreshing = true; try { const freshJobs = await jobCache.refreshJobs(); // Update in memory without interrupting user allJobs = freshJobs; // Reapply current filters and search applyFilters(); console.log(`Background refresh complete (${freshJobs.length} jobs)`); } catch (err) { console.error('Background refresh failed:', err); } finally { isRefreshing = false; } } // Render jobs to DOM function renderJobs(): void { const results = document.getElementById('results') as HTMLDivElement; if (filteredJobs.length === 0) { results.innerHTML = '
No jobs found
'; return; } const start = (currentPage - 1) * perPage; const end = start + perPage; const pageJobs = filteredJobs.slice(start, end); results.innerHTML = pageJobs.map(job => { const isActiveTrue = job.Active === true; const isActiveFalse = job.Active === false; const nameVal = job.Job_Full_Name || ''; const contactVal = job.Contact_Person || ''; return `
Job#
${escapeHtml(String(job.Job_Number||''))}
Job Name:
${escapeHtml(String(nameVal))}
Contact:
${escapeHtml(String(contactVal))}
`; }).join(''); // Add click handlers results.querySelectorAll('[data-job-number]').forEach(el => { el.addEventListener('click', (e) => { const jobNumber = (e.currentTarget as HTMLElement).dataset.jobNumber; if (jobNumber) { openDetails(jobNumber); } }); }); } // Open Details view function openDetails(jobNumber: string): void { // Navigate to details view with job number window.location.href = `/details.html?job=${encodeURIComponent(jobNumber)}`; } // Search functionality function setupSearch(): void { const searchBox = document.getElementById('searchBox') as HTMLInputElement; searchBox.addEventListener('input', () => { const query = searchBox.value.toLowerCase().trim(); if (!query) { filteredJobs = [...allJobs]; } else { filteredJobs = allJobs.filter(job => { return ( job.Job_Number?.toString().toLowerCase().includes(query) || job.Job_Full_Name?.toLowerCase().includes(query) || job.Job_Address?.toLowerCase().includes(query) || job.Contact_Person?.toLowerCase().includes(query) || job.Company_Client?.toLowerCase().includes(query) || job.Notes?.toLowerCase().includes(query) || job.Job_Status?.toLowerCase().includes(query) || job.Status?.toLowerCase().includes(query) || job.Estimator?.toLowerCase().includes(query) ); }); } currentPage = 1; renderJobs(); }); } // Filter functionality function setupFilters(): void { const filterToggle = document.getElementById('filterToggle') as HTMLButtonElement; const filtersPanel = document.getElementById('filtersPanel') as HTMLDivElement; const clearFilters = document.getElementById('clearFilters') as HTMLButtonElement; filterToggle.addEventListener('click', () => { const isHidden = filtersPanel.classList.contains('hidden'); if (isHidden) { filtersPanel.classList.remove('hidden'); filterToggle.textContent = 'Hide Filters'; } else { filtersPanel.classList.add('hidden'); filterToggle.textContent = 'Show Filters'; } }); // Apply filters on change const filterInputs = filtersPanel.querySelectorAll('input[type="checkbox"]'); filterInputs.forEach(input => { input.addEventListener('change', applyFilters); }); clearFilters.addEventListener('click', () => { filterInputs.forEach(input => { (input as HTMLInputElement).checked = false; }); applyFilters(); }); } function applyFilters(): void { const divisions = Array.from(document.querySelectorAll('.filter-division:checked')).map(el => (el as HTMLInputElement).value); const actives = Array.from(document.querySelectorAll('.filter-active:checked')).map(el => (el as HTMLInputElement).value); const estimators = Array.from(document.querySelectorAll('.filter-estimator:checked')).map(el => (el as HTMLInputElement).value.toLowerCase()); const statuses = Array.from(document.querySelectorAll('.filter-status:checked')).map(el => (el as HTMLInputElement).value); filteredJobs = allJobs.filter(job => { const divisionVal = job.Job_Division || job.Division; const activeVal = job.Active === true || job.Active === 'Yes' ? 'Yes' : job.Active === false || job.Active === 'No' ? 'No' : '—'; const estimatorVal = (job.Estimator || '').toLowerCase(); const statusVal = job.Job_Status || job.Status; if (divisions.length > 0 && !divisions.some(d => divisionVal?.startsWith(d))) return false; if (actives.length > 0 && !actives.includes(activeVal)) return false; if (estimators.length > 0 && !estimators.some(e => estimatorVal.includes(e))) return false; if (statuses.length > 0 && !statuses.includes(statusVal || '')) return false; return true; }); currentPage = 1; renderJobs(); } // Voice search function setupVoiceSearch(): void { const voiceBtn = document.getElementById('voiceSearchBtn') as HTMLButtonElement; const micIcon = document.getElementById('micIcon') as SVGElement; const micRecordingIcon = document.getElementById('micRecordingIcon') as SVGElement; const searchBox = document.getElementById('searchBox') as HTMLInputElement; if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) { voiceBtn.style.display = 'none'; return; } const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; const recognition = new SpeechRecognition(); recognition.continuous = false; recognition.interimResults = false; voiceBtn.addEventListener('click', () => { recognition.start(); micIcon.classList.add('hidden'); micRecordingIcon.classList.remove('hidden'); }); recognition.onresult = (event: any) => { const transcript = event.results[0][0].transcript; searchBox.value = transcript; searchBox.dispatchEvent(new Event('input')); }; recognition.onend = () => { micIcon.classList.remove('hidden'); micRecordingIcon.classList.add('hidden'); }; recognition.onerror = () => { micIcon.classList.remove('hidden'); micRecordingIcon.classList.add('hidden'); }; } // Sign out function setupSignOut(): void { const signOutBtn = document.getElementById('signOutBtn') as HTMLButtonElement; signOutBtn.addEventListener('click', async () => { try { await fetch('/api/auth/logout', { method: 'POST' }); window.location.href = '/signin.html'; } catch (err) { console.error('Sign out failed:', err); window.location.href = '/signin.html'; } }); } // Initialize on page load async function init(): Promise { const isAuthenticated = await checkAuth(); if (!isAuthenticated) return; setupSearch(); setupFilters(); setupVoiceSearch(); setupSignOut(); await fetchJobs(); } function escapeHtml(str: string): string { return str .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>'); } // Start init();