331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
// 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<boolean> {
|
|
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<void> {
|
|
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 = '<div class="text-center text-red-600 p-4">Failed to load jobs. Please refresh the page.</div>';
|
|
}
|
|
}
|
|
|
|
// Background refresh (silent, doesn't interrupt user)
|
|
async function refreshJobsInBackground(): Promise<void> {
|
|
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 = '<div class="text-center text-gray-500 p-4">No jobs found</div>';
|
|
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 `
|
|
<div class="bg-white rounded-lg p-2.5 border border-blue-100 shadow-sm cursor-pointer relative flex flex-col justify-center gap-1 hover:bg-gray-50 transition-colors" data-job-number="${escapeHtml(String(job.Job_Number||''))}">
|
|
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job#</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(String(job.Job_Number||''))}</div></div>
|
|
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job Name:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(String(nameVal))}</div></div>
|
|
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Contact:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(String(contactVal))}</div></div>
|
|
<div class="absolute top-2.5 right-2.5 w-3.5 h-3.5 rounded-full border-2 border-white shadow-sm" style="background:${isActiveTrue?'green':(isActiveFalse?'red':'#cbd5e1')}"></div>
|
|
</div>
|
|
`;
|
|
}).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<void> {
|
|
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();
|