Files
Job-Info/frontend/index.html
T

1628 lines
80 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" maximum-scale="1" />
<title>Job Info — Vanilla JS</title>
<script src="https://cdn.tailwindcss.com"></script> <!-- Suppress Tailwind CDN warning for now -->
<script>
if (window.tailwind) window.tailwind.config = { corePlugins: { preflight: true } }
</script><script src="https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/dist/pocketbase.umd.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script src="auth.js"></script>
<script>
// Live reload for development
(function() {
let lastCheck = Date.now();
setInterval(async () => {
try {
const res = await fetch('/version?t=' + Date.now(), { cache: 'no-store' });
if (res.ok) {
const data = await res.json();
const currentVersion = sessionStorage.getItem('appVersion');
if (currentVersion && data.version && currentVersion !== data.version) {
console.log('Changes detected, reloading...');
window.location.reload();
}
sessionStorage.setItem('appVersion', data.version || '');
}
} catch (e) {
console.log('Live reload check failed:', e);
}
}, 1000);
})();
</script>
</head>
<body class="font-sans bg-gray-100 m-0 text-gray-900 overflow-hidden touch-pan-y h-screen flex flex-col">
<div class="max-w-full md:max-w-2xl mx-auto p-4 sm:p-3 text-[17px] flex flex-col flex-1 overflow-hidden">
<div class="w-full bg-gray-400 border border-gray-300 rounded-lg px-0.5 py-0.5 mb-3 shadow-sm flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="h-6 w-[186px] rounded-lg bg-black flex items-center justify-center overflow-hidden">
<img src="assets/CCwhiteApp.png" alt="CC" class="h-6 w-auto object-cover scale-80" loading="lazy">
</div>
</div>
<div class="flex items-center gap-0">
<a id="ideaLink" href="https://idea-form.ccllc.pro" target="_blank" rel="noopener" class="h-6 w-12 rounded-3xl overflow-hidden flex items-center justify-center bg-transparent hover:bg-gray-50/20" title="Submit an idea">
<img src="assets/lightbulb.jpg" alt="Idea" class="h-full w-auto object-cover" loading="lazy">
</a>
<a id="assetInventoryLink" href="https://czflex.sharepoint.com/:x:/r/sites/Team/Shared%20Documents/General/ALFONSO/Comprehensive%20Asset%20Inventory.xlsx?d=w9005440917e945d680e9885f33ccf2ba&csf=1&web=1&e=rifTzf" target="_blank" rel="noopener" class="h-6 w-12 rounded-3xl overflow-hidden flex items-center justify-center bg-transparent hover:bg-gray-50/20" title="Open Asset Inventory">
<img src="assets/PalmIsland.png" alt="Palm Island" class="h-full w-auto object-cover" loading="lazy">
</a>
<a id="operationsLink" href="https://czflex.sharepoint.com/:x:/r/sites/Team/Shared%20Documents/General/ALFONSO/GM%20Rollup.xlsx?d=w3e59f37ee1c04cb798e14f86bd87c968&csf=1&web=1&e=vCnfbR" target="_blank" rel="noopener" class="h-6 w-12 rounded-3xl overflow-hidden flex items-center justify-center bg-transparent hover:bg-gray-50/20" title="Open Operations Dashboard">
<img src="assets/DailyCheck.png" alt="Operations Dashboard" class="h-full w-auto object-cover" loading="lazy">
</a>
</div>
</div>
<h1 class="m-0 mb-3 text-center text-blue-600 text-[33px] font-bold">Job Info</h1>
<div class="w-full bg-blue-50 h-1.5 rounded overflow-hidden mb-3">
<div id="progressBar" class="h-full w-0 bg-blue-600 transition-all duration-300 ease-out"></div>
</div>
<input id="searchBox" placeholder="Search jobs (just start typing)" class="w-full p-2.5 rounded-lg border border-gray-300 mb-2 text-lg placeholder:text-gray-400">
<button id="filterToggle" class="bg-blue-600 text-white py-2 px-2.5 rounded-lg border-none cursor-pointer mb-2 inline-block min-w-[140px]">Show Filters</button>
<div id="filtersPanel" class="hidden bg-white border border-gray-200 rounded-lg p-2.5 mb-2.5">
<div class="mb-2 text-sm">
<strong>Division</strong><br/>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-division" value="C#"> Commercial</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-division" value="R#"> Residential</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-division" value="S#"> Small Jobs</label>
</div>
<div class="mb-2 text-sm">
<strong>Active</strong><br/>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-active" value="Yes"> Yes</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-active" value="No"> No</label>
</div>
<div class="mb-2 text-sm">
<strong>Estimator</strong><br/>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-estimator" value="Steve Brewer"> Steve</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-estimator" value="Beth Cardoza"> Beth</label>
</div>
<div class="mb-2 text-sm">
<strong>Status</strong><br/>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Estimating"> Estimating</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="In Progress"> In Progress</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Awarded"> Awarded</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Billed (In Progress)"> Billed (In Progress)</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Not Bidding"> Not Bidding</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Est Sent"> Est Sent</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Not Awarded"> Not Awarded</label>
</div>
<button id="clearFilters" class="bg-gray-500 text-white py-2 px-2 rounded-lg border-none">Clear Filters</button>
</div>
<div id="results" aria-live="polite" class="flex flex-col gap-3 flex-1 overflow-auto p-0"></div>
<div class="text-xs text-gray-500 mt-5 pt-3 border-t border-gray-200 flex items-center justify-between">
<span id="userEmail" class="truncate"></span>
<div class="flex items-center gap-2">
<button id="signOutBtn" class="px-2 py-1 text-[11px] bg-gray-200 hover:bg-gray-300 rounded border border-gray-300 text-gray-700">Sign out</button>
<span id="versionLabel">v1.0.0-beta2</span>
</div>
</div>
</div>
<!-- Detail Modal -->
<div id="detailModal" role="dialog" aria-modal="true" class="hidden flex fixed inset-0 bg-black/45 items-start justify-center pt-[2vh] z-[999]">
<div id="detailBox" class="m-0 bg-white rounded-lg max-w-[380px] w-[92%] h-[calc(90vh-30px)] max-h-[926px] overflow-hidden flex flex-col">
<!-- INFO Tab Content -->
<div id="infoContent" class="flex-1 overflow-hidden flex flex-col min-h-0">
<div id="infoHeading" class="bg-blue-600 text-white text-center py-2 text-sm font-semibold rounded-t-lg flex-shrink-0">INFO</div>
<div class="px-3 pt-2 pb-1 flex-1 overflow-auto">
<div class="mt-0 w-full mb-1 space-y-2" id="detailTable"></div>
<button id="jobFolderBtn" disabled class="w-full py-2.5 px-3 rounded-lg border-none text-white cursor-pointer text-[17px] font-bold mb-2.5 transition-colors duration-200 bg-gray-600 cursor-not-allowed opacity-70">No Folder Available</button>
</div>
</div>
<!-- NOTES Tab Content -->
<div id="notesContent" class="hidden flex-1 overflow-hidden flex flex-col min-h-0">
<div id="notesHeading" class="bg-orange-600 text-white text-center py-2 text-sm font-semibold rounded-t-lg flex-shrink-0">NOTES</div>
<div class="px-3 pt-2 pb-1 flex-1 overflow-auto">
<div id="jobNotes" class="min-h-[120px] max-h-[200px] overflow-auto p-1 border border-gray-600 rounded text-[11px] bg-transparent whitespace-pre-wrap break-words text-gray-600"></div>
<div class="flex flex-wrap mb-0.5 mt-1.5" id="toolbar">
<button data-cmd="bold" id="tbBold" class="h-7 w-8 text-xs py-0 px-1 rounded border border-gray-400 bg-gray-400 text-gray-900 cursor-pointer flex items-center justify-center m-[1px]"><strong>B</strong></button>
<button data-cmd="italic" id="tbItalic" class="h-7 w-8 text-xs py-0 px-1 rounded border border-gray-400 bg-gray-400 text-gray-900 cursor-pointer flex items-center justify-center m-[1px]"><em>I</em></button>
<button data-cmd="underline" id="tbUnderline" class="h-7 w-8 text-xs py-0 px-1 rounded border border-gray-400 bg-gray-400 text-gray-900 cursor-pointer flex items-center justify-center m-[1px]"><u>U</u></button>
<button data-cmd="foreColor" data-val="#ff0000" id="tbRed" class="h-7 w-8 text-xs py-0 px-1 rounded border border-gray-400 bg-gray-400 text-gray-900 cursor-pointer flex items-center justify-center m-[1px] text-red-500">A</button>
<button data-cmd="foreColor" data-val="#0000ff" id="tbBlue" class="h-7 w-8 text-xs py-0 px-1 rounded border border-gray-400 bg-gray-400 text-gray-900 cursor-pointer flex items-center justify-center m-[1px] text-blue-600">A</button>
<button data-cmd="foreColor" data-val="#000000" id="tbBlack" class="h-7 w-8 text-xs py-0 px-1 rounded border border-gray-400 bg-gray-400 text-gray-900 cursor-pointer flex items-center justify-center m-[1px] text-black">A</button>
<button data-cmd="removeFormat" id="tbClear" class="h-7 w-auto py-1 px-2 text-xs rounded border border-gray-400 bg-gray-400 text-white cursor-pointer flex items-center justify-center m-[1px]">Reset</button>
</div>
<div id="newNote" contenteditable="true" aria-label="New note" class="min-h-14 max-h-[120px] overflow-auto p-2 border border-gray-600 rounded-lg text-base bg-transparent mt-1.5"></div>
<div class="flex gap-2 mt-1.5 mb-4">
<button class="py-2 px-2.5 rounded-lg border-none text-white cursor-pointer bg-green-600" id="btnSubmit">Submit</button>
<button class="py-2 px-2.5 rounded-lg border-none text-white cursor-pointer bg-gray-500" id="btnClearNote">Clear</button>
<button class="py-2 px-2.5 rounded-lg border-none text-white cursor-pointer bg-red-600" id="btnClose">Close</button>
</div>
</div>
</div>
<!-- Tab Bar at Bottom -->
<div class="flex items-center border-t border-gray-200 pt-2 px-3 pb-2 flex-shrink-0 bg-white rounded-b-lg">
<button id="backBtn" class="mr-6 pr-6 border-r border-gray-200 px-3 py-2 text-sm font-medium text-gray-600 hover:text-gray-800 flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
Back
</button>
<button id="infoTab" class="px-4 py-2 text-sm font-medium text-blue-600 border-b-2 border-blue-600 -mb-px">INFO</button>
<button id="notesTab" class="px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700">NOTES</button>
</div>
</div>
</div>
<!-- Iframe Viewer Modal -->
<div id="iframeViewerModal" class="hidden fixed inset-0 bg-black z-[1000] flex flex-col">
<div class="bg-gray-900 text-white px-3 py-3 flex items-center gap-3 flex-shrink-0 shadow-lg">
<button id="iframeBackBtn" aria-label="Back to jobs" class="flex items-center justify-center w-10 h-10 bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</button>
<div class="flex-1 min-w-0 text-center">
<div id="iframeTitle" class="text-sm font-medium truncate px-2">Loading...</div>
</div>
<button id="iframeCloseBtn" aria-label="Close" class="flex items-center justify-center w-10 h-10 bg-red-600 hover:bg-red-700 rounded-lg transition-colors shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="flex-1 relative bg-white flex flex-col overflow-hidden">
<div id="fileListContainer" class="hidden flex-1 flex flex-col p-0 bg-white overflow-hidden"></div>
<div id="pdfViewer" class="hidden flex-1 flex flex-col bg-gray-50">
<div class="flex items-center gap-2 px-3 py-2 border-b border-gray-200 bg-white shadow-sm flex-shrink-0">
<div class="flex items-center gap-1">
<button id="pdfZoomOut" class="px-2 py-1 bg-gray-200 hover:bg-gray-300 rounded"></button>
<button id="pdfZoomReset" class="px-2 py-1 bg-gray-200 hover:bg-gray-300 rounded">45%</button>
<button id="pdfZoomIn" class="px-2 py-1 bg-gray-200 hover:bg-gray-300 rounded">+</button>
<span id="pdfZoomLabel" class="text-sm text-gray-700 ml-1">45%</span>
</div>
<div class="flex items-center gap-2 ml-4">
<button id="pdfPrev" class="px-2 py-1 bg-gray-200 hover:bg-gray-300 rounded"></button>
<button id="pdfNext" class="px-2 py-1 bg-gray-200 hover:bg-gray-300 rounded"></button>
<span class="text-sm text-gray-700">Page <span id="pdfPageNum">1</span> / <span id="pdfPageCount">1</span></span>
</div>
<span class="flex-1"></span>
<div class="flex items-center gap-2 ml-auto">
<button id="pdfCloseAll" aria-label="Close preview" class="flex items-center justify-center w-9 h-9 bg-red-500 hover:bg-red-600 rounded text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<div class="flex-1 overflow-hidden bg-gray-100 flex items-start justify-center">
<canvas id="pdfCanvas" class="bg-white shadow-sm mt-3 mb-6 cursor-grab touch-none"></canvas>
</div>
</div>
<iframe id="iframeViewer" class="w-full h-full border-none" sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-downloads allow-modals"></iframe>
<div id="iframeLoader" class="absolute inset-0 bg-white flex items-center justify-center">
<div class="text-center">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
<div class="text-gray-600 text-lg">Loading content...</div>
</div>
</div>
</div>
</div>
<script>
(async function() {
const { pb, authHeaders, ensureAuth, getDisplayName, getEmail } = window.Auth;
const authed = await ensureAuth();
if (!authed) return;
// Make sure a Graph token is available before proceeding; this may refresh silently
try {
await ensureGraphToken();
} catch (err) {
console.warn('Graph token missing after refresh; continuing. Preview may prompt for auth.', err?.message || err);
}
// --- Utility ---
const fetchNoCache = (url, options={}) => {
const headers = { ...(options.headers||{}), ...authHeaders() };
return fetch(url + `&_ts=${Date.now()}`, { ...options, headers });
};
const GRAPH_TOKEN_KEY = 'graphAccessToken';
const GRAPH_REAUTH_FLAG = 'graphReauthAttempted';
const extractGraphToken = (metaObj={}) => (
metaObj.graphAccessToken ||
metaObj.graph_token ||
metaObj.graphToken ||
metaObj.accessToken ||
metaObj.access_token ||
metaObj.token ||
metaObj.rawToken ||
metaObj?.authData?.access_token ||
''
);
const getGraphToken = () => {
// Prefer runtime token already present in PB model/meta, then localStorage
const model = pb?.authStore?.model || {};
const meta = model?.meta || {};
const token = (
model.graphAccessToken ||
model.graph_token ||
model.graphToken ||
meta.graphAccessToken ||
meta.graph_token ||
meta.graphToken ||
localStorage.getItem(GRAPH_TOKEN_KEY) ||
''
);
console.log('[getGraphToken] Found token:', token ? token.substring(0, 30) + '...' : 'NONE');
return token;
};
const PB_COLLECTION_URL = "https://pocketbase.ccllc.pro/api/collections/pbc_1407612689/records";
const NOTES_COLLECTION_URL = "https://pocketbase.ccllc.pro/api/collections/notes/records";
const PREFS_COLLECTION_URL = "https://pocketbase.ccllc.pro/api/collections/user_preferences/records";
const perPage = 50;
// Notes relation handling: keep it simple; send the job id in a known field
// and avoid schema reads that may require elevated privileges.
let jobsCache = [], visibleJobs = [], currentJob = null;
function clearJobsCache(message='Cache cleared. Reloading…'){
jobsCache = [];
visibleJobs = [];
currentJob = null;
resultsEl.innerHTML = `<div class="p-2 text-slate-600 bg-white rounded-lg border border-blue-100">${message}</div>`;
}
window.clearJobsCache = clearJobsCache;
const progressBar = document.getElementById('progressBar');
const searchBox = document.getElementById('searchBox');
const resultsEl = document.getElementById('results');
const filtersPanel = document.getElementById('filtersPanel');
const filterToggle = document.getElementById('filterToggle');
const userEmailEl = document.getElementById('userEmail');
const signOutBtn = document.getElementById('signOutBtn');
const detailModal = document.getElementById('detailModal');
const detailTable = document.getElementById('detailTable');
const jobFolderBtn = document.getElementById('jobFolderBtn');
const jobNotes = document.getElementById('jobNotes');
const newNote = document.getElementById('newNote');
const versionLabel = document.getElementById('versionLabel');
const toolbar = document.getElementById('toolbar');
const tbButtons = Array.from(toolbar.querySelectorAll('button'));
const safeLower = v => (v||'').toString().trim().toLowerCase();
const toBool = (v) => {
if (typeof v === 'boolean') return v;
const s = String(v||'').trim().toLowerCase();
if (s === 'true' || s === 'yes' || s === 'y' || s === '1') return true;
if (s === 'false' || s === 'no' || s === 'n' || s === '0') return false;
return null;
};
const currentUserEmail = getEmail();
if(userEmailEl && currentUserEmail) userEmailEl.textContent = currentUserEmail;
// Ensure Graph token is present; refresh if missing; redirect to signin if still absent
async function refreshGraphToken(){
try {
const authData = await pb.collection('users').authRefresh();
const meta = authData?.meta || pb?.authStore?.model?.meta || {};
const token = extractGraphToken(meta);
if (token) localStorage.setItem(GRAPH_TOKEN_KEY, token);
return token || '';
} catch(err){
console.warn('Graph token refresh failed:', err?.message||err);
return '';
}
}
async function ensureGraphToken(){
let token = getGraphToken();
if(token) return token;
// If PB session is valid, try to refresh; if still missing, redirect to reauth once
if (pb?.authStore?.isValid) {
token = await refreshGraphToken();
if (token) return token;
const attempted = localStorage.getItem(GRAPH_REAUTH_FLAG) === '1';
if (!attempted) {
localStorage.setItem(GRAPH_REAUTH_FLAG, '1');
console.warn('Graph token unavailable after refresh; redirecting once to sign-in to obtain a new token.');
window.location.href = 'signin.html?reauth=1';
throw new Error('GRAPH_TOKEN missing');
}
console.warn('Graph token unavailable after refresh; reauth already attempted. Proceeding without token.');
return '';
}
// PB session invalid: redirect to signin
window.location.href = 'signin.html';
throw new Error('GRAPH_TOKEN missing');
}
if (signOutBtn) {
signOutBtn.addEventListener('click', () => {
try { pb?.authStore?.clear?.(); } catch {}
try { localStorage.removeItem(GRAPH_TOKEN_KEY); } catch {}
try { localStorage.removeItem('pocketbase_auth'); } catch {}
try { sessionStorage.clear(); } catch {}
location.reload();
});
}
// Load version from backend /version endpoint so UI matches package.json
async function loadVersion(){
if(!versionLabel) return;
try{
const res = await fetch('/version');
if(!res.ok) {
console.warn(`Version endpoint returned ${res.status}. Using default version.`);
return;
}
const data = await res.json().catch(()=>null);
if(data?.version) versionLabel.textContent = `v${data.version}`;
}catch(err){
console.debug('Version load failed (non-critical):', err.message);
}
}
loadVersion();
let progTimer=null;
function startProgress(){
progressBar.style.width='0%';
if(progTimer) clearInterval(progTimer);
let simulatedProg=0;
progTimer=setInterval(()=>{
if(simulatedProg<65){ simulatedProg+=0.8; progressBar.style.width=Math.min(65,simulatedProg).toFixed(1)+'%'; }
},150);
}
function finishProgress(){ if(progTimer) clearInterval(progTimer); progressBar.style.width='100%'; setTimeout(()=>{progressBar.parentElement.classList.add('hidden');},400); }
// --- Fetch all jobs ---
async function fetchAllJobs(){
startProgress();
clearJobsCache('Loading…');
let page=1, totalItems=0;
try{
while(true){
const url = `${PB_COLLECTION_URL}?page=${page}&perPage=${perPage}&sort=-Job_Number`;
const res = await fetchNoCache(url);
if(!res.ok) throw new Error(`Fetch failed: ${res.status}`);
const data = await res.json();
const items = data.items || [];
jobsCache.push(...items);
totalItems = data.totalItems || (items.length + ((page-1)*perPage));
// Apply filters on the currently loaded pages so initial results reflect preferences immediately.
applyFiltersAndRender();
if(progTimer) clearInterval(progTimer);
const realProg=Math.min(95, Math.floor((jobsCache.length / Math.max(1,totalItems)) * 95));
progressBar.style.width=realProg+'%';
if(jobsCache.length >= totalItems || items.length===0) break;
page++;
}
finishProgress();
}catch(err){ finishProgress(); alert('Failed to fetch jobs: '+err.message); console.error(err);}
}
function renderInitialBatch(jobs){
resultsEl.innerHTML='';
for(const job of jobs){
const card=document.createElement('div'); card.className='bg-white rounded-lg p-2.5 border border-blue-100 shadow-sm cursor-pointer relative flex flex-col justify-center gap-1';
card.innerHTML=`
<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(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(job.Job_Full_Name||'')}</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(job.Contact_Person||'')}</div></div>
`;
const dot=document.createElement('div'); dot.className='absolute top-2.5 right-2.5 w-3.5 h-3.5 rounded-full border-2 border-white shadow-sm';
{
const b = toBool(job.Active);
dot.style.background = b===true ? 'green' : (b===false ? 'red' : '#cbd5e1');
}
card.appendChild(dot);
card.addEventListener('click',()=>openDetail(job));
resultsEl.appendChild(card);
}
}
function jobNumberValue(job){
const raw = String(job.Job_Number||'');
const match = raw.match(/[0-9]+\.?[0-9]*/);
if(!match) return null;
const num = parseFloat(match[0]);
return isNaN(num) ? null : num;
}
function sortJobsDescending(jobs){
return jobs.sort((a,b)=>{
const na = jobNumberValue(a);
const nb = jobNumberValue(b);
if(nb!==null && na!==null){ return nb - na; }
if(nb!==null) return 1;
if(na!==null) return -1;
return String(b.Job_Number||'').localeCompare(String(a.Job_Number||''));
});
}
function applyFiltersAndRender(){
const q = safeLower(searchBox.value||'');
const divisionVals = Array.from(document.querySelectorAll('.filter-division:checked')).map(i=>i.value);
const activeVals = Array.from(document.querySelectorAll('.filter-active:checked')).map(i=>i.value);
const activeWanted = activeVals.map(v=>toBool(v)).filter(v=>v!==null);
const statusVals = Array.from(document.querySelectorAll('.filter-status:checked')).map(i=>i.value);
const estimatorVals = Array.from(document.querySelectorAll('.filter-estimator:checked')).map(i=>i.value);
const estimatorValsLower = estimatorVals.map(s=>safeLower(s));
visibleJobs = jobsCache.filter(job=>{
const searchable = [job.Job_Number, job.Job_Full_Name, job.Job_Address, job.Contact_Person, job.Company_Client, job.Notes].join(' ').toLowerCase();
if(q && !searchable.includes(q)) return false;
if(divisionVals.length && !divisionVals.includes(job.Job_Division)) return false;
if(activeWanted.length){
const b = toBool(job.Active);
if(b===null || !activeWanted.includes(b)) return false;
}
if(statusVals.length && !statusVals.includes(job.Job_Status||'')) return false;
if(estimatorVals.length){
const je = safeLower(job.Estimator||'');
const ok = estimatorValsLower.some(v=>je.includes(v) || v.includes(je));
if(!ok) return false;
}
return true;
});
// Data already sorted from PocketBase, only re-sort if filters applied
if(q || divisionVals.length || activeVals.length || statusVals.length || estimatorVals.length){
sortJobsDescending(visibleJobs);
}
console.log('Filter summary', { q, divisionVals, activeWanted, statusVals, estimatorValsLower, total: jobsCache.length, visible: visibleJobs.length });
renderCards(visibleJobs);
}
function renderCards(list){
resultsEl.innerHTML='';
if(!list.length){ resultsEl.innerHTML = `<div class="p-2 text-slate-600 bg-white rounded-lg border border-blue-100">No results</div>`; return; }
const firstChunk = list.slice(0,20);
const remaining = list.slice(20);
for(const job of firstChunk){
const card=document.createElement('div'); card.className='bg-white rounded-lg p-2.5 border border-blue-100 shadow-sm cursor-pointer relative flex flex-col justify-center gap-1';
card.innerHTML=`
<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(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(job.Job_Full_Name||'')}</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(job.Contact_Person||'')}</div></div>
`;
const dot=document.createElement('div'); dot.className='absolute top-2.5 right-2.5 w-3.5 h-3.5 rounded-full border-2 border-white shadow-sm';
{
const b = toBool(job.Active);
dot.style.background = b===true ? 'green' : (b===false ? 'red' : '#cbd5e1');
}
card.appendChild(dot);
card.addEventListener('click',()=>openDetail(job));
resultsEl.appendChild(card);
}
if(remaining.length){
setTimeout(()=>{
for(const job of remaining){
const card=document.createElement('div'); card.className='bg-white rounded-lg p-2.5 border border-blue-100 shadow-sm cursor-pointer relative flex flex-col justify-center gap-1';
card.innerHTML=`
<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(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(job.Job_Full_Name||'')}</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(job.Contact_Person||'')}</div></div>
`;
const dot=document.createElement('div'); dot.className='absolute top-2.5 right-2.5 w-3.5 h-3.5 rounded-full border-2 border-white shadow-sm';
{
const b = toBool(job.Active);
dot.style.background = b===true ? 'green' : (b===false ? 'red' : '#cbd5e1');
}
card.appendChild(dot);
card.addEventListener('click',()=>openDetail(job));
resultsEl.appendChild(card);
}
}, 50);
}
}
function escapeHtml(str){ return String(str).replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;'); }
// --- Filters ---
filterToggle.addEventListener('click',()=>{ filtersPanel.classList.toggle('hidden'); filterToggle.textContent = filtersPanel.classList.contains('hidden')?'Show Filters':'Hide Filters'; });
searchBox.addEventListener('input',()=>applyFiltersAndRender());
document.querySelectorAll('.filter-division').forEach(cb=>cb.addEventListener('change',()=>applyFiltersAndRender()));
document.querySelectorAll('.filter-active').forEach(cb=>{ cb.addEventListener('change',()=>{
if(cb.checked) document.querySelectorAll('.filter-active').forEach(o=>{if(o!==cb)o.checked=false;}); applyFiltersAndRender();
});});
document.querySelectorAll('.filter-estimator').forEach(cb=>{ cb.addEventListener('change',()=>{
if(cb.checked) document.querySelectorAll('.filter-estimator').forEach(o=>{if(o!==cb)o.checked=false;}); applyFiltersAndRender();
});});
document.querySelectorAll('.filter-status').forEach(cb=>cb.addEventListener('change',()=>applyFiltersAndRender()));
document.getElementById('clearFilters').addEventListener('click',()=>{
document.querySelectorAll('.filter-division, .filter-active, .filter-status, .filter-estimator').forEach(cb=>cb.checked=false);
applyFiltersAndRender();
});
// --- Preferences ---
async function loadUserPreferences(){
console.log('loadUserPreferences: email=', currentUserEmail);
if(!currentUserEmail) return null;
try{
const list = await pb.collection('user_preferences').getList(1,1,{
filter: `user_email="${currentUserEmail}"`
});
const record = list?.items?.[0];
const prefs = record?.job_info_prefs || record?.prefs || null;
console.log('loadUserPreferences: fetched prefs=', prefs);
if(!prefs && currentUserEmail.toLowerCase()==='rcardoza@cardoza.construction'){
console.log('loadUserPreferences: using fallback for rcardoza');
return { filters: { active:true, estimator:'Steve Brewer', job_division:'C#' } };
}
return prefs;
}catch(err){
console.warn('loadUserPreferences: error=', err);
const isTarget = currentUserEmail && currentUserEmail.toLowerCase()==='rcardoza@cardoza.construction';
if(isTarget){
console.log('loadUserPreferences: using error fallback for rcardoza');
return { filters: { active:true, estimator:'Steve Brewer', job_division:'C#' } };
}
return null;
}
}
function applyPrefsToFilters(prefs){
console.log('applyPrefsToFilters: received prefs=', prefs);
// Handle both direct filters and nested prefs.filters
const filters = prefs?.filters || prefs?.prefs?.filters;
if(!filters) {
console.log('applyPrefsToFilters: no filters found, returning');
return;
}
const { active, estimator, job_division, status } = filters;
console.log('applyPrefsToFilters: applying filters', filters);
// reset all filters first
document.querySelectorAll('.filter-division, .filter-active, .filter-status, .filter-estimator').forEach(cb=>cb.checked=false);
if(typeof active === 'boolean'){
document.querySelectorAll('.filter-active').forEach(cb=>{
const v = cb.value.trim().toLowerCase();
if((active && v==='yes') || (!active && v==='no')) cb.checked=true;
});
}
if(estimator){
const estLower = String(estimator||'').trim().toLowerCase();
document.querySelectorAll('.filter-estimator').forEach(cb=>{
const cbVal = cb.value.trim().toLowerCase();
if(cbVal === estLower || cbVal.includes(estLower) || estLower.includes(cbVal)) cb.checked=true;
});
// If no estimator got checked, try a direct match to 'steve brewer'
const anyChecked = Array.from(document.querySelectorAll('.filter-estimator')).some(cb=>cb.checked);
if(!anyChecked && estLower.startsWith('steve')){
document.querySelectorAll('.filter-estimator').forEach(cb=>{
if(cb.value.trim().toLowerCase().includes('steve')) cb.checked=true;
});
}
}
if(job_division){
const jd = String(job_division||'').trim().toLowerCase();
let code=null;
if(jd.includes('c#') || jd.includes('commercial')) code='C#';
else if(jd.includes('r#') || jd.includes('residential')) code='R#';
else if(jd.includes('s#') || jd.includes('small')) code='S#';
if(code){
document.querySelectorAll('.filter-division').forEach(cb=>{ if(cb.value===code) cb.checked=true; });
}
}
if(status){
const arr = Array.isArray(status) ? status : [status];
const lowerStatuses = arr.map(s=>String(s||'').trim().toLowerCase());
document.querySelectorAll('.filter-status').forEach(cb=>{
if(lowerStatuses.includes(cb.value.trim().toLowerCase())) cb.checked=true;
});
}
applyFiltersAndRender();
}
// --- Modal & Notes ---
function openDetail(job){
currentJob = job;
// Show modal immediately
detailModal.classList.remove('hidden');
document.getElementById('detailBox').scrollTop = 0;
switchTab('info');
// Show loading state
detailTable.innerHTML = '<div class="text-gray-500 text-sm">Loading...</div>';
// Preload files in background
if (job.Job_Folder_Link) {
preloadJobFiles(job).catch(err => console.log('Background preload error:', err));
}
// Populate details asynchronously
setTimeout(() => populateDetailContent(job), 0);
}
function populateDetailContent(job){
detailTable.innerHTML = '';
const fields = ["Job_Full_Name","Job_Number","Company_Client","Manager","Contact_Person","Phone_Number","Job_Address","Start_Date","Tax_Exempt","Job_Status","Job_Type"];
let i = 0;
while(i < fields.length){
const f = fields[i];
// Handle paired fields
if(f === 'Job_Number'){
const row = document.createElement('div'); row.className='pb-2 border-b border-gray-100 flex gap-4';
// Job Number
const col1 = document.createElement('div'); col1.className='flex-1';
const label1 = document.createElement('div'); label1.className='text-xs font-bold text-gray-700 uppercase tracking-wide mb-1'; label1.textContent='Job Number';
const val1 = document.createElement('div'); val1.className='text-sm text-gray-900'; val1.textContent = job['Job_Number'] || '—';
col1.appendChild(label1); col1.appendChild(val1);
// Company Client
const col2 = document.createElement('div'); col2.className='flex-1';
const label2 = document.createElement('div'); label2.className='text-xs font-bold text-gray-700 uppercase tracking-wide mb-1'; label2.textContent='Company Client';
const val2 = document.createElement('div'); val2.className='text-sm text-gray-900'; val2.textContent = job['Company_Client'] || '—';
col2.appendChild(label2); col2.appendChild(val2);
row.appendChild(col1); row.appendChild(col2); detailTable.appendChild(row);
i += 2; // Skip Company_Client in next iteration
continue;
}
if(f === 'Contact_Person'){
const row = document.createElement('div'); row.className='pb-2 border-b border-gray-100 flex gap-4';
// Contact Person
const col1 = document.createElement('div'); col1.className='flex-1';
const label1 = document.createElement('div'); label1.className='text-xs font-bold text-gray-700 uppercase tracking-wide mb-1'; label1.textContent='Contact Person';
const val1 = document.createElement('div'); val1.className='text-sm text-gray-900'; val1.textContent = job['Contact_Person'] || '—';
col1.appendChild(label1); col1.appendChild(val1);
// Phone Number
const col2 = document.createElement('div'); col2.className='flex-1';
const label2 = document.createElement('div'); label2.className='text-xs font-bold text-gray-700 uppercase tracking-wide mb-1'; label2.textContent='Phone Number';
const val2 = document.createElement('div'); val2.className='text-sm';
if(job['Phone_Number'] && job['Phone_Number'].trim() !== ''){
const phoneLink = document.createElement('a');
const phoneNumber = job['Phone_Number'].replace(/\D/g, ''); // Remove non-digits
phoneLink.href = `tel:${phoneNumber}`;
phoneLink.className = 'text-blue-600 hover:text-blue-800 underline';
phoneLink.textContent = job['Phone_Number'];
val2.appendChild(phoneLink);
} else {
val2.className = 'text-sm text-gray-900';
val2.textContent = '—';
}
col2.appendChild(label2); col2.appendChild(val2);
row.appendChild(col1); row.appendChild(col2); detailTable.appendChild(row);
i += 2; // Skip Phone_Number in next iteration
continue;
}
if(f === 'Job_Status'){
const row = document.createElement('div'); row.className='pb-2 border-b border-gray-100 flex gap-4';
// Job Status
const col1 = document.createElement('div'); col1.className='flex-1';
const label1 = document.createElement('div'); label1.className='text-xs font-bold text-gray-700 uppercase tracking-wide mb-1'; label1.textContent='Job Status';
const val1 = document.createElement('div'); val1.className='text-sm text-gray-900'; val1.textContent = job['Job_Status'] || '—';
col1.appendChild(label1); col1.appendChild(val1);
// Job Type
const col2 = document.createElement('div'); col2.className='flex-1';
const label2 = document.createElement('div'); label2.className='text-xs font-bold text-gray-700 uppercase tracking-wide mb-1'; label2.textContent='Job Type';
const val2 = document.createElement('div'); val2.className='text-sm text-gray-900'; val2.textContent = job['Job_Type'] || '—';
col2.appendChild(label2); col2.appendChild(val2);
row.appendChild(col1); row.appendChild(col2); detailTable.appendChild(row);
i += 2; // Skip Job_Type in next iteration
continue;
}
if(f === 'Manager'){
const row = document.createElement('div'); row.className='pb-2 border-b border-gray-100 flex gap-4';
const col1 = document.createElement('div'); col1.className='flex-1';
const label1 = document.createElement('div'); label1.className='text-xs font-bold text-gray-700 uppercase tracking-wide mb-1'; label1.textContent='Manager';
const val1 = document.createElement('div'); val1.className='text-sm text-gray-900'; val1.textContent = job['Manager'] || '—';
col1.appendChild(label1); col1.appendChild(val1);
const col2 = document.createElement('div'); col2.className='flex-1';
const label2 = document.createElement('div'); label2.className='text-xs font-bold text-gray-700 uppercase tracking-wide mb-1'; label2.textContent='Job Division';
const val2 = document.createElement('div'); val2.className='text-sm text-gray-900'; val2.textContent = job['Job_Division'] || '—';
col2.appendChild(label2); col2.appendChild(val2);
row.appendChild(col1); row.appendChild(col2); detailTable.appendChild(row);
i += 1; // Manager handled with Job_Division
continue;
}
// Regular single field
const value = job[f] || '';
if(!value && f === 'Tax_Exempt') {
i++;
continue; // Skip empty Tax Exempt
}
const row = document.createElement('div'); row.className='pb-2 border-b border-gray-100 last:border-0';
const label = document.createElement('div'); label.className='text-xs font-bold text-gray-700 uppercase tracking-wide mb-1'; label.textContent=f.replace(/_/g,' ');
const val = document.createElement('div'); val.className='text-sm text-gray-900 font-medium';
// Format date if it's Start_Date
if(f === 'Start_Date' && value){
try {
const date = new Date(value);
val.textContent = date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
} catch(e) {
val.textContent = value;
}
} else if(f === 'Job_Address' && value && value.trim() !== ''){
// Create address container with text and map icons
val.className = 'text-sm flex items-center gap-2 flex-wrap';
// Address text
const addressText = document.createElement('span');
addressText.className = 'text-gray-900 flex-1 min-w-0';
addressText.textContent = value;
val.appendChild(addressText);
// Icon container
const iconContainer = document.createElement('div');
iconContainer.className = 'flex items-center gap-2';
// Apple Maps link
const appleLink = document.createElement('a');
const encodedAddress = encodeURIComponent(value);
appleLink.href = `https://maps.apple.com/?q=${encodedAddress}`;
appleLink.target = '_blank';
appleLink.className = 'inline-flex items-center justify-center w-6 h-6 hover:opacity-80 transition-opacity';
appleLink.title = 'Open in Apple Maps';
appleLink.innerHTML = '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" style="fill: #000000;"><title>Apple</title><path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"/></svg>';
iconContainer.appendChild(appleLink);
// Google Maps link
const googleLink = document.createElement('a');
googleLink.href = `https://www.google.com/maps/search/?api=1&query=${encodedAddress}`;
googleLink.target = '_blank';
googleLink.className = 'inline-flex items-center justify-center w-6 h-6 hover:opacity-80 transition-opacity';
googleLink.title = 'Open in Google Maps';
googleLink.innerHTML = '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" style="fill: #4285F4;"><title>Google</title><path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"/></svg>';
iconContainer.appendChild(googleLink);
val.appendChild(iconContainer);
} else {
val.textContent = value || '—';
}
row.appendChild(label); row.appendChild(val); detailTable.appendChild(row);
i++;
}
// Job folder button
if(job.Job_Folder_Link && String(job.Job_Folder_Link).trim()!==''){
try{
const url=new URL(job.Job_Folder_Link);
jobFolderBtn.textContent='Show Job Folder';
jobFolderBtn.disabled=false;
jobFolderBtn.className='w-full py-2.5 px-3 rounded-lg border-none text-white cursor-pointer text-[17px] font-bold mb-2.5 transition-colors duration-200 bg-blue-600 hover:bg-blue-700';
jobFolderBtn.onclick=()=>openJobFiles(job);
}catch(e){
jobFolderBtn.textContent='No Folder Available';
jobFolderBtn.disabled=true;
jobFolderBtn.className='w-full py-2.5 px-3 rounded-lg border-none text-white cursor-pointer text-[17px] font-bold mb-2.5 transition-colors duration-200 bg-gray-600 cursor-not-allowed opacity-70';
jobFolderBtn.onclick=null;
}
} else {
jobFolderBtn.textContent='No Folder Available';
jobFolderBtn.disabled=true;
jobFolderBtn.className='w-full py-2.5 px-3 rounded-lg border-none text-white cursor-pointer text-[17px] font-bold mb-2.5 transition-colors duration-200 bg-gray-600 cursor-not-allowed opacity-70';
jobFolderBtn.onclick=null;
}
jobNotes.innerHTML = job.Notes || '';
newNote.innerHTML = '';
tbButtons.forEach(b=>b.classList.remove('bg-blue-100', 'text-gray-900', 'border-blue-600'));
}
const closeModal = ()=>{ detailModal.classList.add('hidden'); renderCards(visibleJobs); };
document.getElementById('btnClose').addEventListener('click',closeModal);
document.getElementById('backBtn').addEventListener('click',closeModal);
// Tab switching
function switchTab(tab){
const infoTab = document.getElementById('infoTab');
const notesTab = document.getElementById('notesTab');
const infoContent = document.getElementById('infoContent');
const notesContent = document.getElementById('notesContent');
const infoHeading = document.getElementById('infoHeading');
const notesHeading = document.getElementById('notesHeading');
if(tab === 'info'){
infoTab.className = 'px-4 py-2 text-sm font-medium text-blue-600 border-b-2 border-blue-600 -mb-px';
notesTab.className = 'px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700';
infoContent.classList.remove('hidden');
notesContent.classList.add('hidden');
if(infoHeading) infoHeading.classList.remove('hidden');
if(notesHeading) notesHeading.classList.add('hidden');
} else {
infoTab.className = 'px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700';
notesTab.className = 'px-4 py-2 text-sm font-medium text-orange-600 border-b-2 border-orange-600 -mb-px';
infoContent.classList.add('hidden');
notesContent.classList.remove('hidden');
if(infoHeading) infoHeading.classList.add('hidden');
if(notesHeading) notesHeading.classList.remove('hidden');
}
}
document.getElementById('infoTab').addEventListener('click',()=>switchTab('info'));
document.getElementById('notesTab').addEventListener('click',()=>switchTab('notes'));
// --- Toolbar ---
let savedSelection = null;
function saveSelection(){ const sel=window.getSelection(); if(sel.rangeCount>0)savedSelection=sel.getRangeAt(0);}
function restoreSelection(){ const sel=window.getSelection(); if(savedSelection){ sel.removeAllRanges(); sel.addRange(savedSelection);}}
newNote.addEventListener('mouseup',saveSelection); newNote.addEventListener('keyup',saveSelection); newNote.addEventListener('focus',saveSelection);
tbButtons.forEach(btn=>{
btn.addEventListener('mousedown', e=>{
e.preventDefault();
restoreSelection();
const cmd = btn.dataset.cmd;
if(!cmd) return;
if(cmd==='foreColor'){ const val=btn.dataset.val; document.execCommand(cmd,false,val); tbButtons.forEach(b=>{if(b.dataset.cmd==='foreColor'&&b!==btn)b.classList.remove('bg-blue-100', 'text-gray-900', 'border-blue-600');}); btn.classList.toggle('bg-blue-100'); btn.classList.toggle('text-gray-900'); btn.classList.toggle('border-blue-600'); }
else if(cmd==='removeFormat'){ restoreSelection(); const sel=window.getSelection(); if(sel.rangeCount>0){ const range=sel.getRangeAt(0); let content=range.toString(); range.deleteContents(); range.insertNode(document.createTextNode(content)); range.setStartAfter(range.startContainer); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); } tbButtons.forEach(b=>b.classList.remove('bg-blue-100', 'text-gray-900', 'border-blue-600')); }
else{ document.execCommand(cmd,false,null); btn.classList.toggle('bg-blue-100'); btn.classList.toggle('text-gray-900'); btn.classList.toggle('border-blue-600'); }
newNote.focus(); saveSelection();
});
});
// --- Submit Note ---
document.getElementById('btnSubmit').addEventListener('click', async()=>{
const html=newNote.innerHTML.trim(); if(!html){alert('Enter note text'); return;}
const now = new Date();
const timestamp = now.toLocaleString('en-US', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: true });
const currentUserName = getDisplayName();
const formatted=`[${currentUserName} - ${timestamp}] [Job #${currentJob?.Job_Number||'N/A'}] ${html}\n\n`;
const updatedNotes = formatted + (currentJob.Notes||'');
try{
const payloadNote = {
Note: formatted.trim(),
Job_Number: currentJob?.Job_Number || '',
// Job_Number_Id is a multiple relation field, so send as array
Job_Number_Id: currentJob?.id ? [currentJob.id] : []
};
// First: Update job record in prod
const resJob = await fetch(`${PB_COLLECTION_URL}/${currentJob.id}`,{
method:'PATCH',
headers:{'Content-Type':'application/json', ...authHeaders()},
body:JSON.stringify({Notes:updatedNotes})
});
if(!resJob.ok) throw new Error('Job_Info update failed: '+resJob.status);
const updated=await resJob.json();
const jobIdToLink = updated.id || currentJob.id;
// Second: Create note in notes collection with the confirmed job ID
payloadNote.Job_Number_Id = [jobIdToLink];
console.log('Submitting note payload:', payloadNote);
const resNote = await fetch(`${NOTES_COLLECTION_URL}`,{
method:'POST',
headers:{'Content-Type':'application/json', ...authHeaders()},
body:JSON.stringify(payloadNote)
});
// If note creation failed, try fallback by appending job info into the note text
if(!resNote.ok){
const errText = await resNote.text().catch(()=> '');
console.warn('Note creation failed with status', resNote.status, ':', errText);
console.log('Payload that failed:', JSON.stringify(payloadNote, null, 2));
// Retry with fallback if it's a 400 validation error (likely field mismatch)
const shouldFallback = resNote.status === 400;
if(shouldFallback){
const fallbackPayload = {
Note: `${formatted.trim()}\n(Relation fallback: Job #${currentJob?.Job_Number||'N/A'} | ID ${currentJob?.id||'unknown'})`,
Job_Number: currentJob?.Job_Number || ''
};
try{
const retry = await fetch(`${NOTES_COLLECTION_URL}`,{
method:'POST',
headers:{'Content-Type':'application/json', ...authHeaders()},
body:JSON.stringify(fallbackPayload)
});
if(!retry.ok){
const retryText = await retry.text().catch(()=> '');
console.warn('Fallback retry failed:', retry.status, retryText);
}else{
console.log('Fallback retry succeeded');
}
}catch(e){ console.warn('Notes create retry error:', e?.message||e); }
}else{
console.warn('Notes create failed (non-fallback scenario):', resNote.status, errText);
}
}
const idx=jobsCache.findIndex(j=>j.id===currentJob.id); if(idx>=0) jobsCache[idx].Notes=updated.Notes; currentJob.Notes=updated.Notes;
jobNotes.innerHTML=updated.Notes; newNote.innerHTML='';
}catch(err){alert('Error saving note: '+err.message); console.error(err);}
});
document.getElementById('btnClearNote').addEventListener('click',()=>newNote.innerHTML='');
// --- Iframe Viewer Controls ---
const iframeViewerModal = document.getElementById('iframeViewerModal');
const iframeViewer = document.getElementById('iframeViewer');
const iframeLoader = document.getElementById('iframeLoader');
const iframeTitle = document.getElementById('iframeTitle');
const iframeBackBtn = document.getElementById('iframeBackBtn');
const iframeCloseBtn = document.getElementById('iframeCloseBtn');
const fileListContainer = document.getElementById('fileListContainer');
const categoryKeywords = {
contracts: ['contract', 'estimate', 'proposal', 'bid', 'award', 'agreement', 'sow'],
plans: ['plan', 'drawing', 'dwg', 'pdf', 'sheet', 'layout'],
submittals: ['submittal', 'submittals', 'submit'],
};
const DEFAULT_PDF_SCALE = 0.45;
const MIN_PDF_SCALE = 0.3;
const MAX_PDF_SCALE = 3.0;
const fileListState = { items: [], filter: '', jobNumber: '' };
let currentPreviewUrl = null;
let currentPdf = { doc: null, page: 1, pages: 1, scale: DEFAULT_PDF_SCALE };
let pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
const pdfViewer = document.getElementById('pdfViewer');
const pdfCanvas = document.getElementById('pdfCanvas');
const pdfPageNum = document.getElementById('pdfPageNum');
const pdfPageCount = document.getElementById('pdfPageCount');
const pdfPrev = document.getElementById('pdfPrev');
const pdfNext = document.getElementById('pdfNext');
const pdfZoomIn = document.getElementById('pdfZoomIn');
const pdfZoomOut = document.getElementById('pdfZoomOut');
const pdfZoomReset = document.getElementById('pdfZoomReset');
const pdfZoomLabel = document.getElementById('pdfZoomLabel');
const pdfCloseAll = document.getElementById('pdfCloseAll');
const categorize = (name = '') => {
const n = name.toLowerCase();
if (categoryKeywords.contracts.some((w) => n.includes(w))) return 'contracts';
if (categoryKeywords.submittals.some((w) => n.includes(w))) return 'submittals';
if (categoryKeywords.plans.some((w) => n.includes(w))) return 'plans';
return 'other';
};
// File cache for preloading
const fileCache = new Map();
async function preloadJobFiles(job) {
const link = job.Job_Folder_Link;
if (!link || fileCache.has(link)) return; // Skip if no link or already cached
try {
const graphToken = await ensureGraphToken();
const headers = graphToken ? { 'x-graph-token': graphToken } : {};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
let resp = await fetch(`/api/job-files?link=${encodeURIComponent(link)}`, { headers, signal: controller.signal });
clearTimeout(timeout);
// If unauthorized, try to refresh Graph token once, then retry
if (resp.status === 401) {
const refreshed = await refreshGraphToken();
if (refreshed) {
headers['x-graph-token'] = refreshed;
const retryCtl = new AbortController();
const retryTimeout = setTimeout(() => retryCtl.abort(), 15000);
resp = await fetch(`/api/job-files?link=${encodeURIComponent(link)}`, { headers, signal: retryCtl.signal });
clearTimeout(retryTimeout);
}
}
if (resp.ok) {
const data = await resp.json();
const rootDriveId = data.driveId || '';
const items = (data.items || []).map(f => ({
...f,
driveId: f.driveId || rootDriveId
}));
fileCache.set(link, { items, driveId: rootDriveId });
console.log(`Preloaded ${items.length} files for job ${job.Job_Number}`);
}
} catch (err) {
console.log('Preload failed (non-critical):', err);
}
}
const formatSize = (bytes) => {
if (!bytes && bytes !== 0) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
};
const getFileIcon = (name, contentType) => {
const ext = (name || '').toLowerCase().split('.').pop();
const type = (contentType || '').toLowerCase();
if (ext === 'pdf' || type.includes('pdf'))
return '<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none"><rect fill="#D32F2F" width="24" height="24" rx="2"/><text x="12" y="16" font-size="10" font-weight="bold" fill="white" text-anchor="middle">PDF</text></svg>';
if (['doc', 'docx'].includes(ext) || type.includes('word'))
return '<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none"><rect fill="#2B579A" width="24" height="24" rx="2"/><text x="12" y="16" font-size="10" font-weight="bold" fill="white" text-anchor="middle">W</text></svg>';
if (['xls', 'xlsx'].includes(ext) || type.includes('excel') || type.includes('spreadsheet'))
return '<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none"><rect fill="#217346" width="24" height="24" rx="2"/><text x="12" y="16" font-size="10" font-weight="bold" fill="white" text-anchor="middle">X</text></svg>';
if (['ppt', 'pptx'].includes(ext) || type.includes('powerpoint') || type.includes('presentation'))
return '<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none"><rect fill="#D35230" width="24" height="24" rx="2"/><text x="12" y="16" font-size="10" font-weight="bold" fill="white" text-anchor="middle">P</text></svg>';
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg'].includes(ext) || type.includes('image'))
return '<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="20" rx="2"/><circle cx="8" cy="8" r="2"/><path d="M21 15l-5-5L5 21"/></svg>';
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext) || type.includes('zip') || type.includes('compressed'))
return '<svg class="w-6 h-6" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-5-9h-3v4h3v-4zm0-3h-3v2h3V7z"/></svg>';
if (['txt', 'csv', 'log'].includes(ext) || type.includes('text'))
return '<svg class="w-6 h-6" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/><path d="M16 13H8M16 17H8M10 9H8"/></svg>';
return '<svg class="w-6 h-6" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/></svg>';
};
const canPreviewInline = (name, contentType) => {
const ext = (name || '').toLowerCase().split('.').pop();
const type = (contentType || '').toLowerCase();
return ext === 'pdf' || type.includes('pdf') || ['jpg', 'jpeg', 'png', 'gif', 'svg', 'bmp'].includes(ext) || type.includes('image');
};
function openFileInViewer(url, name) {
const jobPrefix = fileListState.jobNumber ? `Job# ${fileListState.jobNumber} - ` : '';
iframeViewerModal.classList.remove('hidden');
iframeTitle.textContent = `${jobPrefix}${name || 'File Preview'}`;
fileListContainer.classList.add('hidden');
pdfViewer.classList.add('hidden');
iframeViewer.classList.remove('hidden');
iframeLoader.classList.remove('hidden');
iframeLoader.innerHTML = `
<div class="text-center">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
<div class="text-gray-600 text-lg">Loading preview...</div>
</div>
`;
iframeViewer.src = 'about:blank';
setTimeout(() => {
iframeViewer.src = url;
}, 50);
}
window.openFileInViewer = openFileInViewer;
async function renderPdfPage() {
const { doc, page, scale } = currentPdf;
if (!doc) return;
const pageObj = await doc.getPage(page);
const viewport = pageObj.getViewport({ scale });
const canvas = pdfCanvas;
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
const renderContext = { canvasContext: context, viewport };
await pageObj.render(renderContext).promise;
pdfPageNum.textContent = page.toString();
pdfPageCount.textContent = doc.numPages.toString();
// re-apply pan transform after render
pdfCanvas.style.transform = `translate(${pdfPan.x}px, ${pdfPan.y}px)`;
pdfZoomLabel.textContent = `${Math.round(currentPdf.scale * 100)}%`;
pdfZoomReset.textContent = `${Math.round(currentPdf.scale * 100)}%`;
}
function showPdfViewer() {
iframeViewer.classList.add('hidden');
pdfViewer.classList.remove('hidden');
iframeLoader.classList.add('hidden');
}
pdfPrev.addEventListener('click', async () => {
if (!currentPdf.doc) return;
if (currentPdf.page <= 1) return;
currentPdf.page -= 1;
await renderPdfPage();
});
pdfNext.addEventListener('click', async () => {
if (!currentPdf.doc) return;
if (currentPdf.page >= currentPdf.pages) return;
currentPdf.page += 1;
await renderPdfPage();
});
pdfZoomIn.addEventListener('click', async () => {
if (!currentPdf.doc) return;
currentPdf.scale = Math.min(currentPdf.scale + 0.1, MAX_PDF_SCALE);
await renderPdfPage();
});
pdfZoomOut.addEventListener('click', async () => {
if (!currentPdf.doc) return;
currentPdf.scale = Math.max(currentPdf.scale - 0.1, MIN_PDF_SCALE);
await renderPdfPage();
});
pdfZoomReset.addEventListener('click', async () => {
if (!currentPdf.doc) return;
currentPdf.scale = DEFAULT_PDF_SCALE;
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
pdfCanvas.style.transform = 'translate(0px, 0px)';
await renderPdfPage();
});
// Drag-to-pan on PDF canvas + pinch-to-zoom
pdfCanvas.style.touchAction = 'none';
const applyPan = () => {
pdfCanvas.style.transform = `translate(${pdfPan.x}px, ${pdfPan.y}px)`;
};
const activePointers = new Map();
let pinchStartDistance = null;
let pinchStartScale = null;
const distanceBetween = () => {
if (activePointers.size < 2) return null;
const pts = Array.from(activePointers.values());
const a = pts[0];
const b = pts[1];
const dx = a.x - b.x;
const dy = a.y - b.y;
return Math.hypot(dx, dy);
};
pdfCanvas.addEventListener('wheel', async (e) => {
if (!currentPdf.doc) return;
e.preventDefault();
const delta = e.deltaY;
const step = 0.1;
if (delta < 0) {
currentPdf.scale = Math.min(currentPdf.scale + step, MAX_PDF_SCALE);
} else {
currentPdf.scale = Math.max(currentPdf.scale - step, MIN_PDF_SCALE);
}
await renderPdfPage();
}, { passive: false });
// Toolbar close (X) goes back to file list
pdfCloseAll.addEventListener('click', () => {
pdfViewer.classList.add('hidden');
iframeLoader.classList.add('hidden');
iframeViewer.classList.add('hidden');
fileListContainer.classList.remove('hidden');
});
pdfCanvas.addEventListener('pointerdown', (e) => {
if (!currentPdf.doc) return;
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (activePointers.size === 2) {
pinchStartDistance = distanceBetween();
pinchStartScale = currentPdf.scale;
pdfPan.dragging = false;
} else if (activePointers.size === 1) {
pdfPan.dragging = true;
pdfPan.startX = e.clientX;
pdfPan.startY = e.clientY;
pdfPan.baseX = pdfPan.x;
pdfPan.baseY = pdfPan.y;
pdfCanvas.style.cursor = 'grabbing';
}
pdfCanvas.setPointerCapture(e.pointerId);
});
pdfCanvas.addEventListener('pointermove', (e) => {
if (!currentPdf.doc) return;
if (activePointers.has(e.pointerId)) {
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
}
if (activePointers.size >= 2 && pinchStartDistance) {
const dist = distanceBetween();
if (dist) {
const scale = pinchStartScale * (dist / pinchStartDistance);
currentPdf.scale = Math.min(Math.max(scale, MIN_PDF_SCALE), MAX_PDF_SCALE);
renderPdfPage();
}
return;
}
if (!pdfPan.dragging) return;
const dx = e.clientX - pdfPan.startX;
const dy = e.clientY - pdfPan.startY;
pdfPan.x = pdfPan.baseX + dx;
pdfPan.y = pdfPan.baseY + dy;
applyPan();
});
const endPan = (e) => {
if (activePointers.has(e.pointerId)) {
activePointers.delete(e.pointerId);
}
if (activePointers.size < 2) {
pinchStartDistance = null;
pinchStartScale = null;
}
if (activePointers.size === 0) {
pdfPan.dragging = false;
pdfCanvas.style.cursor = 'grab';
}
if (e && e.pointerId) {
try { pdfCanvas.releasePointerCapture(e.pointerId); } catch {}
}
};
pdfCanvas.addEventListener('pointerup', endPan);
pdfCanvas.addEventListener('pointercancel', endPan);
async function openFilePreview(driveId, itemId, name, contentType, fallbackUrl) {
// If no drive/item info, just open the URL directly
if (!driveId || !itemId) {
openFileInViewer(fallbackUrl, name);
return;
}
const graphToken = getGraphToken();
if (!graphToken) {
console.warn('Graph token missing; using fallback URL');
openFileInViewer(fallbackUrl, name);
return;
}
iframeViewerModal.classList.remove('hidden');
const jobPrefix = fileListState.jobNumber ? `Job# ${fileListState.jobNumber} - ` : '';
iframeTitle.textContent = `${jobPrefix}${name || 'File Preview'}`;
fileListContainer.classList.add('hidden');
iframeViewer.classList.add('hidden');
pdfViewer.classList.add('hidden');
iframeLoader.classList.remove('hidden');
iframeLoader.innerHTML = `
<div class="text-center">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
<div class="text-gray-600 text-lg">Loading preview...</div>
</div>
`;
// Clear prior blob URL if any
if (currentPreviewUrl) {
URL.revokeObjectURL(currentPreviewUrl);
currentPreviewUrl = null;
}
try {
const res = await fetch(`/api/job-file-content?driveId=${encodeURIComponent(driveId)}&itemId=${encodeURIComponent(itemId)}`, {
headers: { 'x-graph-token': graphToken }
});
if (!res.ok) throw new Error(`Preview fetch failed: ${res.status}`);
const blob = await res.blob();
currentPreviewUrl = URL.createObjectURL(blob);
const isPdf = (contentType || '').toLowerCase().includes('pdf') || (name || '').toLowerCase().endsWith('.pdf');
if (isPdf && window['pdfjsLib']) {
const data = await blob.arrayBuffer();
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
const doc = await pdfjsLib.getDocument({ data }).promise;
currentPdf = { doc, page: 1, pages: doc.numPages, scale: DEFAULT_PDF_SCALE };
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
pdfCanvas.style.transform = 'translate(0px, 0px)';
await renderPdfPage();
showPdfViewer();
} else {
// fallback to iframe blob view (images, etc.)
iframeViewer.src = 'about:blank';
setTimeout(() => {
iframeViewer.src = currentPreviewUrl;
iframeViewer.classList.remove('hidden');
iframeLoader.classList.add('hidden');
}, 10);
}
} catch (err) {
console.error('Preview load error', err);
// Fallback to direct open
openFileInViewer(fallbackUrl, name);
}
}
window.openFilePreview = openFilePreview;
function renderFileGroups(folderLink) {
const term = (fileListState.filter || '').toLowerCase().trim();
const filtered = fileListState.items
.filter((f) => !f.isFolder && f.name.toLowerCase().includes(term));
const groups = { contracts: [], submittals: [], plans: [], other: [] };
filtered.forEach((f) => groups[categorize(f.name)].push(f));
const renderGroup = (title, list) => {
if (!list.length) return '';
const rows = list
.map(
(f) => {
const icon = getFileIcon(f.name, f.contentType);
const canPreview = canPreviewInline(f.name, f.contentType);
const openBtn = canPreview
? `<button class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm" onclick="openFilePreview('${f.driveId||''}', '${f.id||''}', '${f.name.replace(/'/g, "\\'")}', '${f.contentType||''}', '${f.url}')">Preview</button>`
: `<a class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm" href="${f.url}" target="_blank" rel="noopener">Open</a>`;
return `
<div class="flex items-center justify-between py-2 border-b border-gray-100">
<div class="flex items-center min-w-0 gap-2">
<span class="text-gray-600 flex-shrink-0">${icon}</span>
<div class="min-w-0">
<div class="font-medium text-gray-800 truncate" title="${f.name}">${f.name}</div>
<div class="text-xs text-gray-500">
${formatSize(f.size) || '—'} · ${f.modified ? new Date(f.modified).toLocaleDateString() : ''}
</div>
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
${openBtn}
</div>
</div>
`;
}
)
.join('');
return `
<div class="mb-5">
<div class="text-sm font-semibold text-gray-700 mb-2">${title} (${list.length})</div>
<div class="divide-y divide-gray-100 border border-gray-100 rounded-lg bg-white shadow-sm">${rows}</div>
</div>`;
};
const groupsMarkup = `
${renderGroup('Contracts / Estimates', groups.contracts)}
${renderGroup('Submittals', groups.submittals)}
${renderGroup('Plans', groups.plans)}
${renderGroup('Other', groups.other)}
${!filtered.length ? '<div class="text-gray-500 text-sm">No files found.</div>' : ''}
`;
let groupsHost = fileListContainer.querySelector('.file-groups');
if (!groupsHost) {
fileListContainer.innerHTML = `
<div class="flex items-center gap-2 mb-3 file-search-row px-4 pt-4 flex-shrink-0">
<input id="fileSearchInput" type="search" placeholder="Search files" value="${fileListState.filter}" class="flex-1 px-3 py-2 border border-gray-300 rounded-lg" />
<a data-folder-link href="${folderLink}" target="_blank" rel="noopener" class="px-3 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg text-sm text-gray-800">Open folder</a>
</div>
<div class="file-groups flex-1 overflow-auto px-4 pb-4"></div>
`;
groupsHost = fileListContainer.querySelector('.file-groups');
const searchInput = document.getElementById('fileSearchInput');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
fileListState.filter = e.target.value || '';
renderFileGroups(folderLink);
});
}
} else {
const searchInput = document.getElementById('fileSearchInput');
if (searchInput && searchInput.value !== fileListState.filter) {
const wasFocused = document.activeElement === searchInput;
const caret = searchInput.selectionStart || 0;
searchInput.value = fileListState.filter;
if (wasFocused) searchInput.setSelectionRange(caret, caret);
}
const folderAnchor = fileListContainer.querySelector('[data-folder-link]');
if (folderAnchor) folderAnchor.href = folderLink;
}
if (groupsHost) groupsHost.innerHTML = groupsMarkup;
}
async function openJobFiles(job) {
const link = job.Job_Folder_Link;
if (!link) {
alert('No folder link available for this job.');
return;
}
fileListState.filter = '';
fileListState.jobNumber = job.Job_Number || '';
// Show modal in list mode
iframeViewerModal.classList.remove('hidden');
iframeTitle.textContent = job.Job_Number ? `Job# ${job.Job_Number}` : 'Job Files';
fileListContainer.classList.remove('hidden');
iframeViewer.classList.add('hidden');
// Check if we have cached files
const cached = fileCache.get(link);
if (cached) {
console.log('Using cached files for job', job.Job_Number);
fileListState.items = cached.items;
renderFileGroups(link);
return;
}
// If not cached, show loading and fetch
iframeLoader.classList.remove('hidden');
iframeLoader.innerHTML = `
<div class="text-center">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
<div class="text-gray-600 text-lg">Loading files...</div>
</div>
`;
const graphToken = await ensureGraphToken();
const headers = graphToken ? { 'x-graph-token': graphToken } : {};
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
let resp = await fetch(`/api/job-files?link=${encodeURIComponent(link)}`, { headers, signal: controller.signal });
clearTimeout(timeout);
// If unauthorized, try to refresh Graph token once, then retry
if (resp.status === 401) {
const refreshed = await refreshGraphToken();
if (refreshed) {
headers['x-graph-token'] = refreshed;
const retryCtl = new AbortController();
const retryTimeout = setTimeout(() => retryCtl.abort(), 15000);
resp = await fetch(`/api/job-files?link=${encodeURIComponent(link)}`, { headers, signal: retryCtl.signal });
clearTimeout(retryTimeout);
}
}
if (resp.status === 401) throw new Error('AUTH_EXPIRED');
if (resp.status === 500) throw new Error('GRAPH_TOKEN missing or expired');
if (!resp.ok) throw new Error(`File list failed: ${resp.status}`);
const data = await resp.json();
const rootDriveId = data.driveId || '';
const items = (data.items || []).map(f => ({
...f,
driveId: f.driveId || rootDriveId
}));
// Cache the results
fileCache.set(link, { items, driveId: rootDriveId });
fileListState.items = items;
iframeLoader.classList.add('hidden');
renderFileGroups(link);
} catch (err) {
console.error('job-files error', err);
const authExpired = err?.message === 'AUTH_EXPIRED' || String(err || '').includes('401');
const timedOut = err?.name === 'AbortError';
const friendly = authExpired
? 'Your Microsoft Graph session expired. Click Re-authenticate to sign in again.'
: timedOut
? 'Request timed out. If this keeps happening, re-authenticate and retry.'
: (err?.message || 'Unknown error');
iframeLoader.classList.remove('hidden');
iframeLoader.innerHTML = `
<div class="text-center max-w-md mx-auto px-4 space-y-3">
<div class="text-red-600 text-lg font-semibold">Unable to load files</div>
<div class="text-gray-600 text-sm">${friendly}</div>
<div class="flex items-center justify-center gap-2 flex-wrap">
<a href="${link}" target="_blank" rel="noopener" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">Open folder instead</a>
<button id="reauthBtn" class="px-4 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg text-sm text-gray-800">Re-authenticate</button>
</div>
</div>
`;
const reauthBtn = document.getElementById('reauthBtn');
if (reauthBtn) {
reauthBtn.addEventListener('click', () => {
try { pb?.authStore?.clear?.(); } catch {}
try { localStorage.removeItem(GRAPH_TOKEN_KEY); } catch {}
try { localStorage.removeItem('pocketbase_auth'); } catch {}
try { sessionStorage.clear(); } catch {}
window.location.href = 'signin.html';
});
}
}
}
function openIframeViewer(url, title) {
// Detect SharePoint URLs and skip iframe attempt
const isSharePoint = url.includes('sharepoint.com') || url.includes('.sharepoint.');
// If it's SharePoint, skip iframe entirely and open in new tab
if (isSharePoint) {
window.open(url, '_blank');
return;
}
iframeTitle.textContent = title;
iframeLoader.classList.remove('hidden');
iframeLoader.innerHTML = `
<div class="text-center">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
<div class="text-gray-600 text-lg">Loading content...</div>
</div>
`;
iframeViewer.src = '';
iframeViewerModal.classList.remove('hidden');
// Small delay to ensure modal is visible before loading
setTimeout(() => {
iframeViewer.src = url;
}, 50);
}
function closeIframeViewer() {
iframeViewerModal.classList.add('hidden');
fileListContainer.classList.add('hidden');
iframeViewer.classList.remove('hidden');
iframeLoader.classList.add('hidden');
pdfViewer.classList.add('hidden');
currentPdf = { doc: null, page: 1, pages: 1, scale: DEFAULT_PDF_SCALE };
if (currentPreviewUrl) {
URL.revokeObjectURL(currentPreviewUrl);
currentPreviewUrl = null;
}
// Clear iframe src to stop any loading/video/audio and prevent stacking
setTimeout(() => {
iframeViewer.src = 'about:blank';
}, 300);
}
// Hide loader when iframe loads
iframeViewer.addEventListener('load', () => {
// Check if iframe actually loaded content or is blocked
try {
const iframeDoc = iframeViewer.contentDocument || iframeViewer.contentWindow.document;
const currentSrc = iframeViewer.src;
// If iframe loaded but is empty or blocked, show fallback
if (currentSrc && currentSrc !== 'about:blank') {
iframeLoader.classList.add('hidden');
// Try to get title from iframe (will only work for same-origin)
if (iframeDoc && iframeDoc.title) {
iframeTitle.textContent = iframeDoc.title;
}
}
} catch(e) {
// Cross-origin or blocked - if we have a src, it might still work
if (iframeViewer.src && iframeViewer.src !== 'about:blank') {
iframeLoader.classList.add('hidden');
}
}
});
// Show loader if iframe errors
iframeViewer.addEventListener('error', () => {
const target = iframeViewer.src;
closeIframeViewer();
if (target && target !== 'about:blank') window.open(target, '_blank');
});
// Listen for CSP violations (fallback to new tab)
document.addEventListener('securitypolicyviolation', (e) => {
if (e.violatedDirective.includes('frame-ancestors') && !iframeViewerModal.classList.contains('hidden')) {
const target = iframeViewer.src;
closeIframeViewer();
if (target && target !== 'about:blank') window.open(target, '_blank');
}
});
iframeBackBtn.addEventListener('click', closeIframeViewer);
iframeCloseBtn.addEventListener('click', () => { closeIframeViewer(); if (typeof closeModal === 'function') closeModal(); });
// Close on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !iframeViewerModal.classList.contains('hidden')) {
closeIframeViewer();
}
});
// --- Init ---
const prefs = await loadUserPreferences();
console.log('Init: loaded prefs=', prefs);
applyPrefsToFilters(prefs);
console.log('Init: about to fetch jobs');
fetchAllJobs();
})();
</script>
</body>
</html>