Mode2Test/AuthAndToken: assume tokens always fresh with background refresh; fix jobs API to use correct collection; align Home UI to Mode1 (exact fields, styling, strict Active dot); add full-field IndexedDB cache w/ pagination; remove progress bar; dev TLS bypass; rebuild bundles
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
// job-cache.js
|
||||
var DB_NAME = "JobInfoCache";
|
||||
var DB_VERSION = 2;
|
||||
var JOBS_STORE = "jobs";
|
||||
var META_STORE = "metadata";
|
||||
var CACHE_TTL = 5 * 60 * 1000;
|
||||
var PAGE_SIZE = 500;
|
||||
|
||||
class JobCache {
|
||||
db = null;
|
||||
async initialize() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
resolve();
|
||||
};
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains(JOBS_STORE)) {
|
||||
db.createObjectStore(JOBS_STORE, { keyPath: "Job_Number" });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(META_STORE)) {
|
||||
db.createObjectStore(META_STORE);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
async getCachedJobs() {
|
||||
if (!this.db)
|
||||
throw new Error("Database not initialized");
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([JOBS_STORE], "readonly");
|
||||
const store = transaction.objectStore(JOBS_STORE);
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
async isCacheValid() {
|
||||
if (!this.db)
|
||||
return false;
|
||||
try {
|
||||
const metadata = await this.getMetadata();
|
||||
if (!metadata || !metadata.lastUpdated)
|
||||
return false;
|
||||
const age = Date.now() - metadata.lastUpdated;
|
||||
return age < CACHE_TTL;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async saveJobs(jobs) {
|
||||
if (!this.db)
|
||||
throw new Error("Database not initialized");
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([JOBS_STORE, META_STORE], "readwrite");
|
||||
const jobsStore = transaction.objectStore(JOBS_STORE);
|
||||
const metaStore = transaction.objectStore(META_STORE);
|
||||
jobsStore.clear();
|
||||
jobs.forEach((job) => jobsStore.add(job));
|
||||
const metadata = {
|
||||
lastUpdated: Date.now(),
|
||||
version: DB_VERSION
|
||||
};
|
||||
metaStore.put(metadata, "main");
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
async refreshJobs() {
|
||||
try {
|
||||
let page = 1;
|
||||
let all = [];
|
||||
let totalPages = 1;
|
||||
while (page <= totalPages) {
|
||||
const url = `/api/jobs?page=${page}&perPage=${PAGE_SIZE}&sort=-Job_Number`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch jobs (status ${response.status})`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const items = data.items || [];
|
||||
const reportedTotalPages = data.totalPages || 1;
|
||||
all = all.concat(items);
|
||||
totalPages = Math.max(totalPages, reportedTotalPages);
|
||||
page += 1;
|
||||
}
|
||||
await this.saveJobs(all);
|
||||
return all;
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh jobs cache:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
async getMetadata() {
|
||||
if (!this.db)
|
||||
return null;
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([META_STORE], "readonly");
|
||||
const store = transaction.objectStore(META_STORE);
|
||||
const request = store.get("main");
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
async clearCache() {
|
||||
if (!this.db)
|
||||
throw new Error("Database not initialized");
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([JOBS_STORE, META_STORE], "readwrite");
|
||||
transaction.objectStore(JOBS_STORE).clear();
|
||||
transaction.objectStore(META_STORE).clear();
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
async getCacheAge() {
|
||||
const metadata = await this.getMetadata();
|
||||
if (!metadata || !metadata.lastUpdated)
|
||||
return null;
|
||||
return Date.now() - metadata.lastUpdated;
|
||||
}
|
||||
}
|
||||
var jobCache = new JobCache;
|
||||
|
||||
// home.ts
|
||||
var allJobs = [];
|
||||
var filteredJobs = [];
|
||||
var currentPage = 1;
|
||||
var perPage = 50;
|
||||
var isRefreshing = false;
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const response = await fetch("/api/auth/status");
|
||||
const data = await response.json();
|
||||
if (!data.authenticated) {
|
||||
window.location.href = "/signin.html";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Auth check failed:", err);
|
||||
window.location.href = "/signin.html";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function fetchJobs() {
|
||||
try {
|
||||
await jobCache.initialize();
|
||||
const isCacheValid = await jobCache.isCacheValid();
|
||||
if (isCacheValid) {
|
||||
const cachedJobs = await jobCache.getCachedJobs();
|
||||
allJobs = cachedJobs;
|
||||
filteredJobs = [...allJobs];
|
||||
renderJobs();
|
||||
const cacheAge = await jobCache.getCacheAge();
|
||||
console.log(`Loaded ${allJobs.length} jobs from cache (age: ${Math.round((cacheAge || 0) / 1000)}s)`);
|
||||
refreshJobsInBackground();
|
||||
} else {
|
||||
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);
|
||||
const results = document.getElementById("results");
|
||||
results.innerHTML = '<div class="text-center text-red-600 p-4">Failed to load jobs. Please refresh the page.</div>';
|
||||
}
|
||||
}
|
||||
async function refreshJobsInBackground() {
|
||||
if (isRefreshing)
|
||||
return;
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const freshJobs = await jobCache.refreshJobs();
|
||||
allJobs = freshJobs;
|
||||
applyFilters();
|
||||
console.log(`Background refresh complete (${freshJobs.length} jobs)`);
|
||||
} catch (err) {
|
||||
console.error("Background refresh failed:", err);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
function renderJobs() {
|
||||
const results = document.getElementById("results");
|
||||
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("");
|
||||
results.querySelectorAll("[data-job-number]").forEach((el) => {
|
||||
el.addEventListener("click", (e) => {
|
||||
const jobNumber = e.currentTarget.dataset.jobNumber;
|
||||
if (jobNumber) {
|
||||
openDetails(jobNumber);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function openDetails(jobNumber) {
|
||||
window.location.href = `/details.html?job=${encodeURIComponent(jobNumber)}`;
|
||||
}
|
||||
function setupSearch() {
|
||||
const searchBox = document.getElementById("searchBox");
|
||||
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();
|
||||
});
|
||||
}
|
||||
function setupFilters() {
|
||||
const filterToggle = document.getElementById("filterToggle");
|
||||
const filtersPanel = document.getElementById("filtersPanel");
|
||||
const clearFilters = document.getElementById("clearFilters");
|
||||
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";
|
||||
}
|
||||
});
|
||||
const filterInputs = filtersPanel.querySelectorAll('input[type="checkbox"]');
|
||||
filterInputs.forEach((input) => {
|
||||
input.addEventListener("change", applyFilters);
|
||||
});
|
||||
clearFilters.addEventListener("click", () => {
|
||||
filterInputs.forEach((input) => {
|
||||
input.checked = false;
|
||||
});
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
function applyFilters() {
|
||||
const divisions = Array.from(document.querySelectorAll(".filter-division:checked")).map((el) => el.value);
|
||||
const actives = Array.from(document.querySelectorAll(".filter-active:checked")).map((el) => el.value);
|
||||
const estimators = Array.from(document.querySelectorAll(".filter-estimator:checked")).map((el) => el.value.toLowerCase());
|
||||
const statuses = Array.from(document.querySelectorAll(".filter-status:checked")).map((el) => el.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();
|
||||
}
|
||||
function setupVoiceSearch() {
|
||||
const voiceBtn = document.getElementById("voiceSearchBtn");
|
||||
const micIcon = document.getElementById("micIcon");
|
||||
const micRecordingIcon = document.getElementById("micRecordingIcon");
|
||||
const searchBox = document.getElementById("searchBox");
|
||||
if (!("webkitSpeechRecognition" in window) && !("SpeechRecognition" in window)) {
|
||||
voiceBtn.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const SpeechRecognition = window.SpeechRecognition || window.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) => {
|
||||
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");
|
||||
};
|
||||
}
|
||||
function setupSignOut() {
|
||||
const signOutBtn = document.getElementById("signOutBtn");
|
||||
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";
|
||||
}
|
||||
});
|
||||
}
|
||||
async function init() {
|
||||
const isAuthenticated = await checkAuth();
|
||||
if (!isAuthenticated)
|
||||
return;
|
||||
setupSearch();
|
||||
setupFilters();
|
||||
setupVoiceSearch();
|
||||
setupSignOut();
|
||||
await fetchJobs();
|
||||
}
|
||||
function escapeHtml(str) {
|
||||
return str.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
init();
|
||||
@@ -0,0 +1,330 @@
|
||||
// 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();
|
||||
@@ -0,0 +1,129 @@
|
||||
// job-cache.ts
|
||||
var DB_NAME = "JobInfoCache";
|
||||
var DB_VERSION = 2;
|
||||
var JOBS_STORE = "jobs";
|
||||
var META_STORE = "metadata";
|
||||
var CACHE_TTL = 5 * 60 * 1000;
|
||||
var PAGE_SIZE = 500;
|
||||
|
||||
class JobCache {
|
||||
db = null;
|
||||
async initialize() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
resolve();
|
||||
};
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains(JOBS_STORE)) {
|
||||
db.createObjectStore(JOBS_STORE, { keyPath: "Job_Number" });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(META_STORE)) {
|
||||
db.createObjectStore(META_STORE);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
async getCachedJobs() {
|
||||
if (!this.db)
|
||||
throw new Error("Database not initialized");
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([JOBS_STORE], "readonly");
|
||||
const store = transaction.objectStore(JOBS_STORE);
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
async isCacheValid() {
|
||||
if (!this.db)
|
||||
return false;
|
||||
try {
|
||||
const metadata = await this.getMetadata();
|
||||
if (!metadata || !metadata.lastUpdated)
|
||||
return false;
|
||||
const age = Date.now() - metadata.lastUpdated;
|
||||
return age < CACHE_TTL;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async saveJobs(jobs) {
|
||||
if (!this.db)
|
||||
throw new Error("Database not initialized");
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([JOBS_STORE, META_STORE], "readwrite");
|
||||
const jobsStore = transaction.objectStore(JOBS_STORE);
|
||||
const metaStore = transaction.objectStore(META_STORE);
|
||||
jobsStore.clear();
|
||||
jobs.forEach((job) => jobsStore.add(job));
|
||||
const metadata = {
|
||||
lastUpdated: Date.now(),
|
||||
version: DB_VERSION
|
||||
};
|
||||
metaStore.put(metadata, "main");
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
async refreshJobs() {
|
||||
try {
|
||||
let page = 1;
|
||||
let all = [];
|
||||
let totalPages = 1;
|
||||
while (page <= totalPages) {
|
||||
const url = `/api/jobs?page=${page}&perPage=${PAGE_SIZE}&sort=-Job_Number`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch jobs (status ${response.status})`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const items = data.items || [];
|
||||
const reportedTotalPages = data.totalPages || 1;
|
||||
all = all.concat(items);
|
||||
totalPages = Math.max(totalPages, reportedTotalPages);
|
||||
page += 1;
|
||||
}
|
||||
await this.saveJobs(all);
|
||||
return all;
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh jobs cache:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
async getMetadata() {
|
||||
if (!this.db)
|
||||
return null;
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([META_STORE], "readonly");
|
||||
const store = transaction.objectStore(META_STORE);
|
||||
const request = store.get("main");
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
async clearCache() {
|
||||
if (!this.db)
|
||||
throw new Error("Database not initialized");
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction([JOBS_STORE, META_STORE], "readwrite");
|
||||
transaction.objectStore(JOBS_STORE).clear();
|
||||
transaction.objectStore(META_STORE).clear();
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
async getCacheAge() {
|
||||
const metadata = await this.getMetadata();
|
||||
if (!metadata || !metadata.lastUpdated)
|
||||
return null;
|
||||
return Date.now() - metadata.lastUpdated;
|
||||
}
|
||||
}
|
||||
var jobCache = new JobCache;
|
||||
export {
|
||||
jobCache
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
// PERMANENT: IndexedDB cache for jobs data
|
||||
// Provides lightning-fast job listing with zero-delay loading
|
||||
//
|
||||
// Strategy:
|
||||
// - Display cached jobs instantly on page load (0ms delay)
|
||||
// - Fetch fresh data in background after displaying cache
|
||||
// - Cache TTL: 5 minutes (configurable)
|
||||
// - Stores minimal fields for card display only
|
||||
//
|
||||
// Fields cached: Job_Number, Job_Name, Division, Active, Status, Estimator
|
||||
//
|
||||
// Usage:
|
||||
// await jobCache.initialize();
|
||||
// const jobs = await jobCache.getCachedJobs(); // Instant
|
||||
// await jobCache.refreshJobs(); // Background refresh
|
||||
|
||||
export interface JobCard {
|
||||
Job_Number: string;
|
||||
Job_Name: string;
|
||||
Division: string;
|
||||
Active: string;
|
||||
Status: string;
|
||||
Estimator: string;
|
||||
}
|
||||
|
||||
interface CacheMetadata {
|
||||
lastUpdated: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
const DB_NAME = 'JobInfoCache';
|
||||
const DB_VERSION = 2; // bump to invalidate old minimal-field cache
|
||||
const JOBS_STORE = 'jobs';
|
||||
const META_STORE = 'metadata';
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
const PAGE_SIZE = 500; // match Mode1 bulk fetch
|
||||
|
||||
class JobCache {
|
||||
private db: IDBDatabase | null = null;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
// Create jobs store with Job_Number as key
|
||||
if (!db.objectStoreNames.contains(JOBS_STORE)) {
|
||||
db.createObjectStore(JOBS_STORE, { keyPath: 'Job_Number' });
|
||||
}
|
||||
|
||||
// Create metadata store
|
||||
if (!db.objectStoreNames.contains(META_STORE)) {
|
||||
db.createObjectStore(META_STORE);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getCachedJobs(): Promise<JobCard[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([JOBS_STORE], 'readonly');
|
||||
const store = transaction.objectStore(JOBS_STORE);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async isCacheValid(): Promise<boolean> {
|
||||
if (!this.db) return false;
|
||||
|
||||
try {
|
||||
const metadata = await this.getMetadata();
|
||||
if (!metadata || !metadata.lastUpdated) return false;
|
||||
|
||||
const age = Date.now() - metadata.lastUpdated;
|
||||
return age < CACHE_TTL;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async saveJobs(jobs: JobCard[]): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([JOBS_STORE, META_STORE], 'readwrite');
|
||||
const jobsStore = transaction.objectStore(JOBS_STORE);
|
||||
const metaStore = transaction.objectStore(META_STORE);
|
||||
|
||||
// Clear existing jobs
|
||||
jobsStore.clear();
|
||||
|
||||
// Save new jobs
|
||||
jobs.forEach(job => jobsStore.add(job));
|
||||
|
||||
// Update metadata
|
||||
const metadata: CacheMetadata = {
|
||||
lastUpdated: Date.now(),
|
||||
version: DB_VERSION
|
||||
};
|
||||
metaStore.put(metadata, 'main');
|
||||
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
async refreshJobs(): Promise<JobCard[]> {
|
||||
try {
|
||||
// Fetch fresh data from backend with pagination to mirror Mode1 behavior
|
||||
let page = 1;
|
||||
let all: JobCard[] = [];
|
||||
let totalPages = 1;
|
||||
|
||||
while (page <= totalPages) {
|
||||
const url = `/api/jobs?page=${page}&perPage=${PAGE_SIZE}&sort=-Job_Number`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch jobs (status ${response.status})`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const items: JobCard[] = data.items || [];
|
||||
const reportedTotalPages = data.totalPages || 1;
|
||||
|
||||
all = all.concat(items);
|
||||
totalPages = Math.max(totalPages, reportedTotalPages);
|
||||
page += 1;
|
||||
}
|
||||
|
||||
// Save to cache
|
||||
await this.saveJobs(all);
|
||||
|
||||
return all;
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh jobs cache:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getMetadata(): Promise<CacheMetadata | null> {
|
||||
if (!this.db) return null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([META_STORE], 'readonly');
|
||||
const store = transaction.objectStore(META_STORE);
|
||||
const request = store.get('main');
|
||||
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async clearCache(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([JOBS_STORE, META_STORE], 'readwrite');
|
||||
|
||||
transaction.objectStore(JOBS_STORE).clear();
|
||||
transaction.objectStore(META_STORE).clear();
|
||||
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getCacheAge(): Promise<number | null> {
|
||||
const metadata = await this.getMetadata();
|
||||
if (!metadata || !metadata.lastUpdated) return null;
|
||||
|
||||
return Date.now() - metadata.lastUpdated;
|
||||
}
|
||||
}
|
||||
|
||||
export const jobCache = new JobCache();
|
||||
Reference in New Issue
Block a user