Files

3014 lines
142 KiB
HTML
Raw Permalink 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>
<!-- Preload critical images for instant display -->
<link rel="preload" href="assets/CCwhiteApp.png" as="image">
<link rel="preload" href="assets/lightbulb.jpg" as="image">
<link rel="preload" href="assets/PalmIsland.png" as="image">
<link rel="preload" href="assets/DailyCheck.png" as="image">
<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>
<div class="relative mb-2">
<input id="searchBox" placeholder="Search jobs (just start typing or use voice)" class="w-full p-2.5 pr-12 rounded-lg border border-gray-300 text-lg placeholder:text-gray-400">
<button id="voiceSearchBtn" class="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full flex items-center justify-center hover:bg-gray-100 transition-colors" title="Voice search">
<svg id="micIcon" class="w-5 h-5 text-gray-600 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path>
</svg>
<svg id="micRecordingIcon" class="hidden w-5 h-5 text-red-600 animate-pulse" fill="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8"></circle>
</svg>
</button>
</div>
<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>
<!-- MANAGER INFO Tab Content -->
<div id="managerInfoContent" class="hidden flex-1 overflow-hidden flex flex-col min-h-0">
<div id="managerInfoHeading" class="bg-green-600 text-white text-center py-2 text-sm font-semibold rounded-t-lg flex-shrink-0">MANAGER INFO</div>
<div class="px-3 pt-2 pb-1 flex-1 overflow-auto">
<div id="managerInfoTable" class="w-full space-y-3"></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>
<button id="managerInfoTab" class="px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700">MANAGER INFO</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 id="pdfControls" 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() };
const separator = url.includes('?') ? '&' : '?';
return fetch(url + `${separator}_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…');
const startTime = performance.now();
let pollCount = 0;
async function pollJobs(){
try{
const url = `/api/jobs-all`;
const res = await fetchNoCache(url);
if(!res.ok) throw new Error(`Fetch failed: ${res.status}`);
const data = await res.json();
const items = data.items || [];
if (pollCount === 0 || jobsCache.length === 0) {
jobsCache.length = 0;
jobsCache.push(...items);
progressBar.style.width='95%';
applyFiltersAndRender();
}
if (data.partial) {
// If partial, poll again after short delay
pollCount++;
if (pollCount < 10) setTimeout(pollJobs, 600);
else finishProgress();
} else {
finishProgress();
const loadTime = Math.round(performance.now() - startTime);
console.log(`✓ Loaded ${items.length} jobs from API in ${loadTime}ms`);
}
}catch(err){ finishProgress(); alert('Failed to fetch jobs: '+err.message); console.error(err); }
}
pollJobs();
}
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(){
let q = safeLower(searchBox.value||'');
// Check for recency keywords
const recencyKeywords = ['most recent', 'newest', 'latest', 'current', 'recent'];
let sortByRecent = false;
// Check if query contains any recency keywords (check longer phrases first)
for (const keyword of recencyKeywords) {
if (q.includes(keyword)) {
sortByRecent = true;
// Remove the keyword from search query (use global replace)
const regex = new RegExp(keyword, 'gi');
q = q.replace(regex, ' ');
}
}
// Clean up multiple spaces and trim
q = q.replace(/\s+/g, ' ').trim();
// Update the display to show cleaned query
if (sortByRecent && searchBox.value !== q) {
searchBox.value = q.split(' ').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
}
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;
});
// If recency keyword used, show only THE most recent match
if(sortByRecent && visibleJobs.length > 0){
console.log('Before sorting, visibleJobs count:', visibleJobs.length);
// Sort by Job_Number (higher number = newer job)
visibleJobs.sort((a, b) => {
const numA = jobNumberValue(a) || 0;
const numB = jobNumberValue(b) || 0;
return numB - numA; // Higher job number first (newest)
});
console.log('After sorting, newest job:', visibleJobs[0]?.Job_Number);
// Keep only the most recent
visibleJobs = visibleJobs.slice(0, 1);
console.log('✓ Showing only the most recent match');
} else if(q || divisionVals.length || activeVals.length || statusVals.length || estimatorVals.length){
sortJobsDescending(visibleJobs);
}
console.log('Filter summary', {
originalSearch: searchBox.value,
cleanedQ: q,
sortByRecent,
resultsCount: 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;'); }
// --- Voice Search ---
const voiceSearchBtn = document.getElementById('voiceSearchBtn');
const micIcon = document.getElementById('micIcon');
const micRecordingIcon = document.getElementById('micRecordingIcon');
let recognition = null;
let isListening = false;
let hasPermission = false;
// Check if we're on HTTPS or localhost (required for mic access)
const isSecureContext = window.isSecureContext || location.protocol === 'https:' || location.hostname === 'localhost';
if (!isSecureContext) {
console.warn('⚠️ Voice search requires HTTPS or localhost');
voiceSearchBtn.style.display = 'none';
}
// Request microphone permission explicitly
async function requestMicPermission() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Stop the stream immediately, we just wanted to request permission
stream.getTracks().forEach(track => track.stop());
hasPermission = true;
console.log('✅ Microphone permission granted');
return true;
} catch (err) {
console.error('❌ Microphone permission denied:', err);
alert('🎤 Please allow microphone access to use voice search.\n\nClick the microphone icon in your browser\'s address bar to enable it.');
return false;
}
}
// Check if browser supports speech recognition
if (isSecureContext && ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window)) {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
recognition = new SpeechRecognition();
recognition.continuous = true; // Changed to true to keep listening
recognition.interimResults = true;
recognition.lang = 'en-US';
recognition.maxAlternatives = 1;
let autoStopTimer = null;
let silenceTimer = null;
let lastResultIndex = 0;
let baseSearchText = '';
recognition.onstart = () => {
isListening = true;
lastResultIndex = 0;
baseSearchText = searchBox.value.trim();
// Change mic icon to filled maroon/red
micIcon.style.fill = '#991b1b'; // Tailwind red-800
micIcon.style.stroke = '#991b1b';
// Auto-stop after 10 seconds as a safety measure
autoStopTimer = setTimeout(() => {
if (isListening) {
recognition.stop();
}
}, 10000);
};
recognition.onresult = (event) => {
// Clear any existing silence timer
if (silenceTimer) {
clearTimeout(silenceTimer);
silenceTimer = null;
}
// Process only new final results
for (let i = lastResultIndex; i < event.results.length; i++) {
if (event.results[i].isFinal) {
let transcript = event.results[i][0].transcript.trim();
// Remove trailing punctuation (period, comma, etc.)
transcript = transcript.replace(/[.,!?;:]+$/, '');
// Remove trailing "job" if present
transcript = transcript.replace(/\s+job$/i, '');
// Replace "new" with "latest"
transcript = transcript.replace(/\bnew\b/gi, 'latest');
// Capitalize each word (title case)
transcript = transcript.split(' ').map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(' ');
if (transcript) {
// Add space if base text exists and doesn't end with space
if (baseSearchText && !baseSearchText.endsWith(' ')) {
baseSearchText += ' ';
}
baseSearchText += transcript;
}
lastResultIndex = i + 1;
}
}
// Update search box with accumulated text
searchBox.value = baseSearchText;
// Trigger search with results
applyFiltersAndRender();
// If exactly 1 result, automatically open Manager Info
if (visibleJobs.length === 1) {
const job = visibleJobs[0];
currentJob = job;
detailModal.classList.remove('hidden');
document.getElementById('detailBox').scrollTop = 0;
switchTab('managerInfo');
populateManagerInfo(job);
}
// Check if all results are final, if so prepare to stop
const allFinal = Array.from(event.results).every(r => r.isFinal);
if (allFinal && event.results.length > 0) {
silenceTimer = setTimeout(() => {
recognition.stop();
}, 1500);
}
};
recognition.onspeechstart = () => {};
recognition.onspeechend = () => {};
recognition.onaudiostart = () => {};
recognition.onaudioend = () => {};
recognition.onsoundstart = () => {};
recognition.onsoundend = () => {};
recognition.onerror = (event) => {
// Clear timers
if (autoStopTimer) clearTimeout(autoStopTimer);
if (silenceTimer) clearTimeout(silenceTimer);
isListening = false;
if (event.error === 'not-allowed' || event.error === 'permission-denied') {
alert('🎤 Microphone access denied.\n\nPlease:\n1. Click the microphone icon in your browser\'s address bar\n2. Allow microphone access\n3. Refresh the page');
hasPermission = false;
} else if (event.error === 'audio-capture') {
alert('🎤 Microphone error. Please check:\n1. Your microphone is connected\n2. It\'s not being used by another app\n3. Browser has microphone access');
}
};
recognition.onend = () => {
// Clear timers
if (autoStopTimer) clearTimeout(autoStopTimer);
if (silenceTimer) clearTimeout(silenceTimer);
// Reset mic icon to normal
micIcon.style.fill = 'none';
micIcon.style.stroke = '';
isListening = false;
};
voiceSearchBtn.addEventListener('click', async () => {
if (isListening) {
recognition.stop();
return;
}
// Check if text is highlighted and clear it if so
if (searchBox.selectionStart === 0 && searchBox.selectionEnd === searchBox.value.length && searchBox.value.length > 0) {
searchBox.value = '';
baseSearchText = '';
}
// Request permission first time
if (!hasPermission) {
const granted = await requestMicPermission();
if (!granted) {
return;
}
}
// Reset state
isListening = false;
try {
recognition.start();
} catch (err) {
if (err.message && (err.message.includes('already started') || err.name === 'InvalidStateError')) {
recognition.stop();
setTimeout(() => {
try {
recognition.start();
} catch (e) {
alert('Voice recognition error. Please refresh the page and try again.');
}
}, 300);
} else {
alert('Could not start voice recognition: ' + err.message);
}
}
});
} else {
// Hide voice button if not supported
if (voiceSearchBtn) voiceSearchBtn.style.display = 'none';
console.warn('⚠️ Speech recognition not supported in this browser');
}
// --- Filters ---
filterToggle.addEventListener('click',()=>{ filtersPanel.classList.toggle('hidden'); filterToggle.textContent = filtersPanel.classList.contains('hidden')?'Show Filters':'Hide Filters'; });
// Select all text on first click, normal behavior on second click
let searchBoxSelected = false;
searchBox.addEventListener('focus', () => {
if (!searchBoxSelected) {
searchBox.select();
searchBoxSelected = true;
}
});
searchBox.addEventListener('mousedown', () => {
if (searchBoxSelected) {
searchBoxSelected = false;
}
});
searchBox.addEventListener('blur', () => {
searchBoxSelected = false;
});
searchBox.addEventListener('input',()=>{
// Remove periods added by voice dictation
searchBox.value = searchBox.value.replace(/\./g, '');
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['Project_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'));
}
async function populateManagerInfo(job) {
const managerInfoTable = document.getElementById('managerInfoTable');
managerInfoTable.innerHTML = '';
// Row 1: Job Full Name (spans 2 cols), Manager, Job Status
const row1 = document.createElement('div');
row1.className = 'grid grid-cols-4 gap-4 pb-3 border-b border-gray-200';
const jobFullNameDiv = document.createElement('div');
jobFullNameDiv.className = 'col-span-2';
jobFullNameDiv.innerHTML = `<div class="font-bold text-gray-700 uppercase tracking-wide mb-1" style="font-size: 8px;">Job Full Name</div><div class="text-gray-900 truncate" style="font-size: 10px;" title="${job.Job_Full_Name || '—'}">${job.Job_Full_Name || '—'}</div>`;
const managerDiv = document.createElement('div');
managerDiv.innerHTML = `<div class="font-bold text-gray-700 uppercase tracking-wide mb-1" style="font-size: 8px;">Manager</div><div class="text-gray-900" style="font-size: 10px;">${job.Project_Manager || '—'}</div>`;
const jobStatusDiv = document.createElement('div');
jobStatusDiv.innerHTML = `<div class="font-bold text-gray-700 uppercase tracking-wide mb-1" style="font-size: 8px;">Job Status</div><div class="text-gray-900" style="font-size: 10px;">${job.Job_Status || '—'}</div>`;
row1.appendChild(jobFullNameDiv);
row1.appendChild(managerDiv);
row1.appendChild(jobStatusDiv);
managerInfoTable.appendChild(row1);
// Row 2: Job Number, Job Division, Job Type, Scope
const row2 = document.createElement('div');
row2.className = 'grid grid-cols-4 gap-4 pb-3 border-b border-gray-200';
const jobNum = document.createElement('div');
jobNum.innerHTML = `<div class="font-bold text-gray-700 uppercase tracking-wide mb-1 text-center" style="font-size: 8px;">Job Number</div><div class="text-gray-900 text-center" style="font-size: 10px;">${job.Job_Number || '—'}</div>`;
const jobDivision = document.createElement('div');
jobDivision.innerHTML = `<div class="font-bold text-gray-700 uppercase tracking-wide mb-1 text-center" style="font-size: 8px;">Job Division</div><div class="text-gray-900 text-center" style="font-size: 10px;">${job.Job_Division || '—'}</div>`;
const jobType = document.createElement('div');
jobType.innerHTML = `<div class="font-bold text-gray-700 uppercase tracking-wide mb-1 text-center" style="font-size: 8px;">Job Type</div><div class="text-gray-900 text-center" style="font-size: 10px;">${job.Job_Type || '—'}</div>`;
const scopeDiv = document.createElement('div');
scopeDiv.innerHTML = `<div class="font-bold text-gray-700 uppercase tracking-wide mb-1 text-center" style="font-size: 8px;">Scope</div><div class="text-gray-900 text-center" style="font-size: 10px;">${job.Scope || '—'}</div>`;
row2.appendChild(jobNum);
row2.appendChild(jobDivision);
row2.appendChild(jobType);
row2.appendChild(scopeDiv);
managerInfoTable.appendChild(row2);
// Row 3: Company/Client, Contact Person, Contact Number, EXT.
const row3 = document.createElement('div');
row3.className = 'grid grid-cols-4 gap-4 pb-3 border-b border-gray-200';
const companyClient = document.createElement('div');
companyClient.innerHTML = `<div class="font-bold text-gray-700 uppercase tracking-wide mb-1 text-center" style="font-size: 8px;">Company/Client</div><div class="text-gray-900 text-center" style="font-size: 10px;">${job.Company_Client || '—'}</div>`;
const contactPerson = document.createElement('div');
contactPerson.innerHTML = `<div class="font-bold text-gray-700 uppercase tracking-wide mb-1 text-center" style="font-size: 8px;">Contact Person</div><div class="text-gray-900 text-center" style="font-size: 10px;">${job.Contact_Person || '—'}</div>`;
const contactNumber = document.createElement('div');
contactNumber.innerHTML = `<div class="font-bold text-gray-700 uppercase tracking-wide mb-1 text-center" style="font-size: 8px;">Contact Number</div>`;
const phoneDiv = document.createElement('div');
phoneDiv.className = 'text-center';
phoneDiv.style.fontSize = '10px';
if(job.Phone_Number && job.Phone_Number.trim() !== ''){
const phoneLink = document.createElement('a');
const phoneNumber = job.Phone_Number.replace(/\D/g, '');
phoneLink.href = `tel:${phoneNumber}`;
phoneLink.className = 'text-blue-600 hover:text-blue-800 underline';
phoneLink.textContent = job.Phone_Number;
phoneDiv.appendChild(phoneLink);
} else {
phoneDiv.className = 'text-gray-900 text-center';
phoneDiv.textContent = '—';
}
contactNumber.appendChild(phoneDiv);
const extn = document.createElement('div');
extn.innerHTML = `<div class="font-bold text-gray-700 uppercase tracking-wide mb-1 text-center" style="font-size: 8px;">EXT.</div><div class="text-gray-900 text-center" style="font-size: 10px;">${job.EXT || '—'}</div>`;
row3.appendChild(companyClient);
row3.appendChild(contactPerson);
row3.appendChild(contactNumber);
row3.appendChild(extn);
managerInfoTable.appendChild(row3);
// Row 4: Job Address (spans 2 cols), Map Icons (3rd col), EST Cost (4th col)
const row4 = document.createElement('div');
row4.className = 'grid grid-cols-4 gap-4 pb-3 border-b border-gray-200';
// Job Address spanning 2 columns
const addressContainer = document.createElement('div');
addressContainer.className = 'col-span-2';
addressContainer.innerHTML = `<div class="font-bold text-gray-700 uppercase tracking-wide mb-1" style="font-size: 8px;">Job Address</div>`;
const addressVal = document.createElement('div');
addressVal.style.fontSize = '10px';
addressVal.className = 'text-gray-900';
addressVal.textContent = job.Job_Address || '—';
addressContainer.appendChild(addressVal);
// Map Icons in 3rd column
const mapIconsContainer = document.createElement('div');
mapIconsContainer.className = 'flex items-end gap-2';
if(job.Job_Address && job.Job_Address.trim() !== ''){
const appleLink = document.createElement('a');
const encodedAddress = encodeURIComponent(job.Job_Address);
appleLink.href = `https://maps.apple.com/?q=${encodedAddress}`;
appleLink.target = '_blank';
appleLink.className = 'inline-flex items-center justify-center w-5 h-5 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-4 h-4" 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>';
mapIconsContainer.appendChild(appleLink);
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-5 h-5 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-4 h-4" 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>';
mapIconsContainer.appendChild(googleLink);
}
// EST Cost in 4th column (centered)
const estimatedCostContainer = document.createElement('div');
let costFormatted = '—';
if(job.Estimated_Cost !== null && job.Estimated_Cost !== undefined && job.Estimated_Cost !== '') {
const costNum = parseFloat(job.Estimated_Cost);
if(!isNaN(costNum)) {
costFormatted = '$' + costNum.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
}
estimatedCostContainer.innerHTML = `<div class="font-bold text-gray-700 uppercase tracking-wide mb-1 text-center" style="font-size: 8px;">EST Cost</div><div class="text-gray-900 text-center" style="font-size: 10px;">${costFormatted}</div>`;
row4.appendChild(addressContainer);
row4.appendChild(mapIconsContainer);
row4.appendChild(estimatedCostContainer);
managerInfoTable.appendChild(row4);
// Row 5: EST Start Date, Actual Start Date, EST End Date
const row5 = document.createElement('div');
row5.className = 'grid grid-cols-3 gap-4 pb-3 border-b border-gray-200';
// Format the start date for both estimated and actual (short format)
let startDateFormatted = '—';
if(job.Start_Date){
try {
const date = new Date(job.Start_Date);
startDateFormatted = date.toLocaleDateString('en-US', { year: 'numeric', month: '2-digit', day: '2-digit' });
} catch(e) {
startDateFormatted = job.Start_Date;
}
}
const estimatedStartDate = document.createElement('div');
estimatedStartDate.innerHTML = `<div class="font-bold text-gray-700 uppercase tracking-wide mb-1 text-center" style="font-size: 8px;">EST Start Date</div><div class="text-gray-900 text-center" style="font-size: 10px;">${startDateFormatted}</div>`;
let actualStartDateFormatted = '—';
if(job.Start_Date_Actual){
try {
const date = new Date(job.Start_Date_Actual);
actualStartDateFormatted = date.toLocaleDateString('en-US', { year: 'numeric', month: '2-digit', day: '2-digit' });
} catch(e) {
actualStartDateFormatted = job.Start_Date_Actual;
}
}
const actualStartDate = document.createElement('div');
actualStartDate.innerHTML = `<div class="font-bold text-gray-700 uppercase tracking-wide mb-1 text-center" style="font-size: 8px;">Actual Start Date</div><div class="text-gray-900 text-center" style="font-size: 10px;">${actualStartDateFormatted}</div>`;
let estimatedEndDateFormatted = '—';
if(job.Estimated_End_Date){
try {
const date = new Date(job.Estimated_End_Date);
estimatedEndDateFormatted = date.toLocaleDateString('en-US', { year: 'numeric', month: '2-digit', day: '2-digit' });
} catch(e) {
estimatedEndDateFormatted = job.Estimated_End_Date;
}
}
const estimatedEndDate = document.createElement('div');
estimatedEndDate.innerHTML = `<div class="font-bold text-gray-700 uppercase tracking-wide mb-1 text-center" style="font-size: 8px;">EST End Date</div><div class="text-gray-900 text-center" style="font-size: 10px;">${estimatedEndDateFormatted}</div>`;
row5.appendChild(estimatedStartDate);
row5.appendChild(actualStartDate);
row5.appendChild(estimatedEndDate);
managerInfoTable.appendChild(row5);
// Notes Section with Tabs
const notesSection = document.createElement('div');
notesSection.className = 'mt-4';
// Tab buttons
const notesTabs = document.createElement('div');
notesTabs.className = 'flex gap-2 mb-2';
const jobNotesTabBtn = document.createElement('button');
jobNotesTabBtn.id = 'jobNotesTab';
jobNotesTabBtn.className = 'px-3 py-1 font-bold uppercase bg-orange-600 text-white rounded cursor-pointer';
jobNotesTabBtn.style.fontSize = '8px';
jobNotesTabBtn.textContent = 'Notes';
const managerNotesTabBtn = document.createElement('button');
managerNotesTabBtn.id = 'managerNotesTab';
managerNotesTabBtn.className = 'px-3 py-1 font-bold uppercase bg-gray-300 text-gray-700 rounded hover:bg-gray-400 cursor-pointer';
managerNotesTabBtn.style.fontSize = '8px';
managerNotesTabBtn.textContent = 'Manager Notes';
notesTabs.appendChild(jobNotesTabBtn);
notesTabs.appendChild(managerNotesTabBtn);
notesSection.appendChild(notesTabs);
// Job Notes Content
const jobNotesContent = document.createElement('div');
jobNotesContent.id = 'jobNotesContent';
jobNotesContent.className = 'notes-tab-content';
// Container for display with expand button
const jobNotesDisplayContainer = document.createElement('div');
jobNotesDisplayContainer.className = 'relative mb-3';
// Display notes first
const jobNotesDisplay = document.createElement('div');
jobNotesDisplay.id = 'jobNotesDisplay';
jobNotesDisplay.className = 'text-gray-600 whitespace-pre-wrap break-words p-2 border border-gray-300 rounded bg-gray-50 overflow-y-auto';
jobNotesDisplay.style.fontSize = '10px';
jobNotesDisplay.style.height = '120px';
jobNotesDisplay.style.maxHeight = '120px';
jobNotesDisplay.innerHTML = job.Notes || '<span class="text-gray-400 italic">No notes available</span>';
jobNotesDisplayContainer.appendChild(jobNotesDisplay);
jobNotesContent.appendChild(jobNotesDisplayContainer);
// Add Note button below notes display
const addNoteBtn = document.createElement('button');
addNoteBtn.className = 'mb-2 px-3 py-1 bg-green-600 text-white rounded font-bold hover:bg-green-700 cursor-pointer';
addNoteBtn.style.fontSize = '8px';
addNoteBtn.textContent = 'Add A Note';
jobNotesContent.appendChild(addNoteBtn);
// Note editor (collapsed by default)
const noteEditor = document.createElement('div');
noteEditor.id = 'jobNoteEditor';
noteEditor.className = 'hidden mb-3 p-2 border border-gray-300 rounded bg-gray-50';
noteEditor.innerHTML = `
<div class="flex flex-wrap gap-1 mb-2">
<button data-cmd="bold" class="h-6 w-8 rounded border border-gray-400 bg-gray-400 text-gray-900 cursor-pointer flex items-center justify-center" style="font-size: 8px;"><strong>B</strong></button>
<button data-cmd="italic" class="h-6 w-8 rounded border border-gray-400 bg-gray-400 text-gray-900 cursor-pointer flex items-center justify-center" style="font-size: 8px;"><em>I</em></button>
<button data-cmd="underline" class="h-6 w-8 rounded border border-gray-400 bg-gray-400 text-gray-900 cursor-pointer flex items-center justify-center" style="font-size: 8px;"><u>U</u></button>
<button data-cmd="foreColor" data-val="#ff0000" class="h-6 w-8 rounded border border-gray-400 bg-gray-400 text-gray-900 cursor-pointer flex items-center justify-center text-red-500" style="font-size: 8px;">A</button>
<button data-cmd="foreColor" data-val="#0000ff" class="h-6 w-8 rounded border border-gray-400 bg-gray-400 text-gray-900 cursor-pointer flex items-center justify-center text-blue-600" style="font-size: 8px;">A</button>
<button data-cmd="foreColor" data-val="#000000" class="h-6 w-8 rounded border border-gray-400 bg-gray-400 text-gray-900 cursor-pointer flex items-center justify-center text-black" style="font-size: 8px;">A</button>
<button data-cmd="removeFormat" class="h-6 px-2 rounded border border-gray-400 bg-gray-400 text-white cursor-pointer flex items-center justify-center" style="font-size: 8px;">Reset</button>
<button id="jobNoteMic" class="h-6 w-8 rounded border border-gray-400 bg-gray-400 cursor-pointer flex items-center justify-center" title="Voice to text">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="#4B5563">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
</button>
</div>
<div id="jobNewNote" contenteditable="true" class="min-h-14 max-h-[120px] overflow-auto p-2 border border-gray-600 rounded text-sm bg-white mb-2"></div>
<div class="flex gap-2">
<button id="jobNoteSubmit" class="px-3 py-1 rounded text-white bg-green-600 hover:bg-green-700 font-bold cursor-pointer" style="font-size: 8px;">Submit</button>
<button id="jobNoteClear" class="px-3 py-1 rounded text-white bg-gray-500 hover:bg-gray-600 font-bold cursor-pointer" style="font-size: 8px;">Clear</button>
<button id="jobNoteCollapse" class="px-3 py-1 rounded text-white bg-red-600 hover:bg-red-700 font-bold cursor-pointer" style="font-size: 8px;">Close</button>
</div>
`;
jobNotesContent.appendChild(noteEditor);
// File columns - always create them
const fileColumns = document.createElement('div');
fileColumns.id = 'jobNotesFileColumns';
fileColumns.className = 'grid grid-cols-3 gap-4 mt-3';
// Initialize with empty state
fileColumns.innerHTML = `
<div>
<div class="font-bold text-gray-700 uppercase tracking-wide mb-1" style="font-size: 8px;">Plans</div>
<div class="text-gray-400 italic" style="font-size: 10px;">Loading...</div>
</div>
<div>
<div class="font-bold text-gray-700 uppercase tracking-wide mb-1" style="font-size: 8px;">Estimates</div>
<div class="text-gray-400 italic" style="font-size: 10px;">Loading...</div>
</div>
<div>
<div class="font-bold text-gray-700 uppercase tracking-wide mb-1" style="font-size: 8px;">Other Files</div>
<div class="text-gray-400 italic" style="font-size: 10px;">Loading...</div>
</div>
`;
jobNotesContent.appendChild(fileColumns);
// Manager Notes Content
const managerNotesContent = document.createElement('div');
managerNotesContent.id = 'managerNotesContent';
managerNotesContent.className = 'notes-tab-content hidden';
// Container for display with expand button
const managerNotesDisplayContainer = document.createElement('div');
managerNotesDisplayContainer.className = 'relative mb-3';
// Display manager notes first
const managerNotesDisplay = document.createElement('div');
managerNotesDisplay.id = 'managerNotesDisplay';
managerNotesDisplay.className = 'text-gray-600 whitespace-pre-wrap break-words p-2 border border-gray-300 rounded bg-gray-50 overflow-y-auto';
managerNotesDisplay.style.fontSize = '10px';
managerNotesDisplay.style.height = '120px';
managerNotesDisplay.style.maxHeight = '120px';
managerNotesDisplay.innerHTML = '<span class="text-gray-400 italic">No manager notes available</span>';
managerNotesDisplayContainer.appendChild(managerNotesDisplay);
managerNotesContent.appendChild(managerNotesDisplayContainer);
// Add Note button for manager below notes display
const addManagerNoteBtn = document.createElement('button');
addManagerNoteBtn.className = 'mb-2 px-3 py-1 bg-green-600 text-white rounded font-bold hover:bg-green-700 cursor-pointer';
addManagerNoteBtn.style.fontSize = '8px';
addManagerNoteBtn.textContent = 'Add A Note';
managerNotesContent.appendChild(addManagerNoteBtn);
// Manager note editor (collapsed by default)
const managerNoteEditor = document.createElement('div');
managerNoteEditor.id = 'managerNoteEditor';
managerNoteEditor.className = 'hidden mb-3 p-2 border border-gray-300 rounded bg-gray-50';
managerNoteEditor.innerHTML = `
<div class="flex flex-wrap gap-1 mb-2">
<button data-cmd="bold" class="h-6 w-8 rounded border border-gray-400 bg-gray-400 text-gray-900 cursor-pointer flex items-center justify-center" style="font-size: 8px;"><strong>B</strong></button>
<button data-cmd="italic" class="h-6 w-8 rounded border border-gray-400 bg-gray-400 text-gray-900 cursor-pointer flex items-center justify-center" style="font-size: 8px;"><em>I</em></button>
<button data-cmd="underline" class="h-6 w-8 rounded border border-gray-400 bg-gray-400 text-gray-900 cursor-pointer flex items-center justify-center" style="font-size: 8px;"><u>U</u></button>
<button data-cmd="foreColor" data-val="#ff0000" class="h-6 w-8 rounded border border-gray-400 bg-gray-400 text-gray-900 cursor-pointer flex items-center justify-center text-red-500" style="font-size: 8px;">A</button>
<button data-cmd="foreColor" data-val="#0000ff" class="h-6 w-8 rounded border border-gray-400 bg-gray-400 text-gray-900 cursor-pointer flex items-center justify-center text-blue-600" style="font-size: 8px;">A</button>
<button data-cmd="foreColor" data-val="#000000" class="h-6 w-8 rounded border border-gray-400 bg-gray-400 text-gray-900 cursor-pointer flex items-center justify-center text-black" style="font-size: 8px;">A</button>
<button data-cmd="removeFormat" class="h-6 px-2 rounded border border-gray-400 bg-gray-400 text-white cursor-pointer flex items-center justify-center" style="font-size: 8px;">Reset</button>
<button id="managerNoteMic" class="h-6 w-8 rounded border border-gray-400 bg-gray-400 cursor-pointer flex items-center justify-center" title="Voice to text">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="#4B5563">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
</button>
</div>
<div id="managerNewNote" contenteditable="true" class="min-h-14 max-h-[120px] overflow-auto p-2 border border-gray-600 rounded text-sm bg-white mb-2"></div>
<div class="flex gap-2">
<button id="managerNoteSubmit" class="px-3 py-1 rounded text-white bg-green-600 hover:bg-green-700 font-bold cursor-pointer" style="font-size: 8px;">Submit</button>
<button id="managerNoteClear" class="px-3 py-1 rounded text-white bg-gray-500 hover:bg-gray-600 font-bold cursor-pointer" style="font-size: 8px;">Clear</button>
<button id="managerNoteCollapse" class="px-3 py-1 rounded text-white bg-red-600 hover:bg-red-700 font-bold cursor-pointer" style="font-size: 8px;">Close</button>
</div>
`;
managerNotesContent.appendChild(managerNoteEditor);
// File columns for manager notes - always create them
const managerFileColumns = document.createElement('div');
managerFileColumns.id = 'managerNotesFileColumns';
managerFileColumns.className = 'grid grid-cols-3 gap-4 mt-3';
// Initialize with empty state
managerFileColumns.innerHTML = `
<div>
<div class="font-bold text-gray-700 uppercase tracking-wide mb-1" style="font-size: 8px;">Plans</div>
<div class="text-gray-400 italic" style="font-size: 10px;">Loading...</div>
</div>
<div>
<div class="font-bold text-gray-700 uppercase tracking-wide mb-1" style="font-size: 8px;">Estimates</div>
<div class="text-gray-400 italic" style="font-size: 10px;">Loading...</div>
</div>
<div>
<div class="font-bold text-gray-700 uppercase tracking-wide mb-1" style="font-size: 8px;">Other Files</div>
<div class="text-gray-400 italic" style="font-size: 10px;">Loading...</div>
</div>
`;
managerNotesContent.appendChild(managerFileColumns);
notesSection.appendChild(jobNotesContent);
notesSection.appendChild(managerNotesContent);
managerInfoTable.appendChild(notesSection);
// Tab switching event listeners
jobNotesTabBtn.addEventListener('click', (e) => {
console.log('Notes tab clicked');
e.preventDefault();
e.stopPropagation();
jobNotesTabBtn.className = 'px-3 py-1 text-xs font-bold uppercase bg-orange-600 text-white rounded cursor-pointer';
managerNotesTabBtn.className = 'px-3 py-1 text-xs font-bold uppercase bg-gray-300 text-gray-700 rounded hover:bg-gray-400 cursor-pointer';
jobNotesContent.classList.remove('hidden');
managerNotesContent.classList.add('hidden');
});
managerNotesTabBtn.addEventListener('click', (e) => {
console.log('Manager Notes tab clicked');
e.preventDefault();
e.stopPropagation();
managerNotesTabBtn.className = 'px-3 py-1 text-xs font-bold uppercase bg-orange-600 text-white rounded cursor-pointer';
jobNotesTabBtn.className = 'px-3 py-1 text-xs font-bold uppercase bg-gray-300 text-gray-700 rounded hover:bg-gray-400 cursor-pointer';
managerNotesContent.classList.remove('hidden');
jobNotesContent.classList.add('hidden');
});
// Note editor controls
addNoteBtn.addEventListener('click', (e) => {
console.log('Add Note clicked');
e.preventDefault();
addNoteBtn.classList.add('hidden');
noteEditor.classList.remove('hidden');
});
addManagerNoteBtn.addEventListener('click', (e) => {
console.log('Add Manager Note clicked');
e.preventDefault();
addManagerNoteBtn.classList.add('hidden');
managerNoteEditor.classList.remove('hidden');
});
// Populate file columns
if(job.Job_Folder_Link && String(job.Job_Folder_Link).trim() !== ''){
console.log('Fetching files for notes sections...');
loadFilesForNotes(job.Job_Folder_Link).then(files => {
console.log('Files fetched, populating columns:', files.length);
populateNotesFileColumns(files, 'jobNotesFileColumns');
populateNotesFileColumns(files, 'managerNotesFileColumns');
}).catch(err => {
console.log('Error loading files for notes:', err);
// Show empty state on error
populateNotesFileColumns([], 'jobNotesFileColumns');
populateNotesFileColumns([], 'managerNotesFileColumns');
});
} else {
console.log('No Job_Folder_Link, showing empty state');
// Show empty state when no folder link
populateNotesFileColumns([], 'jobNotesFileColumns');
populateNotesFileColumns([], 'managerNotesFileColumns');
}
// Load and display notes filtered by type
loadAndDisplayNotes(job);
// Setup toolbar functionality for job notes editor
setupNoteEditorToolbar('jobNewNote', 'jobNoteEditor');
// Setup toolbar functionality for manager notes editor
setupNoteEditorToolbar('managerNewNote', 'managerNoteEditor');
// Setup voice-to-text for job notes
setupVoiceToText('jobNoteMic', 'jobNewNote');
// Setup voice-to-text for manager notes
setupVoiceToText('managerNoteMic', 'managerNewNote');
// Setup close functionality
document.getElementById('jobNoteCollapse').addEventListener('click', (e) => {
console.log('Close job note editor');
e.preventDefault();
noteEditor.classList.add('hidden');
addNoteBtn.classList.remove('hidden');
});
document.getElementById('managerNoteCollapse').addEventListener('click', (e) => {
console.log('Close manager note editor');
e.preventDefault();
managerNoteEditor.classList.add('hidden');
addManagerNoteBtn.classList.remove('hidden');
});
// Setup submit functionality
document.getElementById('jobNoteSubmit').addEventListener('click', async(e) => {
e.preventDefault();
await submitNote('jobNewNote', job, false);
noteEditor.classList.add('hidden');
addNoteBtn.classList.remove('hidden');
});
document.getElementById('jobNoteClear').addEventListener('click', (e) => {
e.preventDefault();
document.getElementById('jobNewNote').innerHTML = '';
});
document.getElementById('managerNoteSubmit').addEventListener('click', async(e) => {
e.preventDefault();
await submitNote('managerNewNote', job, true);
managerNoteEditor.classList.add('hidden');
addManagerNoteBtn.classList.remove('hidden');
});
document.getElementById('managerNoteClear').addEventListener('click', (e) => {
e.preventDefault();
document.getElementById('managerNewNote').innerHTML = '';
});
}
// Load files for notes sections
async function loadFilesForNotes(folderLink) {
console.log('loadFilesForNotes called with:', folderLink);
const graphToken = await ensureGraphToken();
const headers = graphToken ? { 'x-graph-token': graphToken } : {};
try {
const resp = await fetch(`/api/job-files?link=${encodeURIComponent(folderLink)}`, { headers });
if(!resp.ok) {
console.error('Failed to fetch files:', resp.status, resp.statusText);
return [];
}
const data = await resp.json();
console.log('Files loaded:', data.items?.length || 0);
return data.items || [];
} catch(err) {
console.error('Error in loadFilesForNotes:', err);
return [];
}
}
// Setup voice-to-text functionality
function setupVoiceToText(micButtonId, editorId) {
const micBtn = document.getElementById(micButtonId);
const editor = document.getElementById(editorId);
if(!micBtn || !editor) {
console.log('Mic button or editor not found:', micButtonId, editorId);
return;
}
// Check for browser support
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if(!SpeechRecognition) {
micBtn.disabled = true;
micBtn.title = 'Speech recognition not supported';
micBtn.style.opacity = '0.5';
return;
}
const recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'en-US';
let isListening = false;
const micIcon = micBtn.querySelector('svg');
micBtn.addEventListener('click', (e) => {
e.preventDefault();
if(isListening) {
recognition.stop();
isListening = false;
micIcon.setAttribute('fill', '#4B5563'); // Gray (inactive)
micBtn.style.backgroundColor = '';
} else {
try {
recognition.start();
isListening = true;
micIcon.setAttribute('fill', '#EF4444'); // Red (recording)
micBtn.style.backgroundColor = '#FEE2E2'; // Light red background
} catch(err) {
console.error('Recognition start error:', err);
}
}
});
recognition.onresult = (event) => {
let interimTranscript = '';
let finalTranscript = '';
for(let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if(event.results[i].isFinal) {
finalTranscript += transcript + ' ';
} else {
interimTranscript += transcript;
}
}
if(finalTranscript) {
// Insert final transcript at cursor position
editor.focus();
const selection = window.getSelection();
if(selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
range.insertNode(document.createTextNode(finalTranscript));
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
} else {
// Fallback: append to end
editor.innerHTML += finalTranscript;
}
}
};
recognition.onerror = (event) => {
console.error('Speech recognition error:', event.error);
isListening = false;
micIcon.setAttribute('fill', '#4B5563');
micBtn.style.backgroundColor = '';
if(event.error === 'not-allowed') {
alert('Microphone access denied. Please allow microphone access in your browser settings.');
}
};
recognition.onend = () => {
isListening = false;
micIcon.setAttribute('fill', '#4B5563');
micBtn.style.backgroundColor = '';
};
}
// Setup toolbar for note editors
function setupNoteEditorToolbar(editorId, containerId) {
const editor = document.getElementById(editorId);
const container = document.getElementById(containerId);
if(!editor || !container) return;
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);
}
}
editor.addEventListener('mouseup', saveSelection);
editor.addEventListener('keyup', saveSelection);
editor.addEventListener('focus', saveSelection);
const buttons = container.querySelectorAll('[data-cmd]');
buttons.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);
buttons.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);
}
buttons.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');
}
editor.focus();
saveSelection();
});
});
}
// Submit note function
async function submitNote(editorId, job, isManagerNote) {
const editor = document.getElementById(editorId);
const html = editor.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 noteType = isManagerNote ? 'manager' : 'job';
const typeLabel = isManagerNote ? '[MANAGER]' : '';
const formatted = `${typeLabel}[${currentUserName} - ${timestamp}] ${html}`;
try {
// If it's a job note, first update Job_Info_Prod.Notes field
let jobRecordId = job?.id || '';
if(!isManagerNote) {
// Fetch the latest notes from PocketBase to ensure we have the full chain
const fetchLatest = await fetch(`${PB_COLLECTION_URL}/${job.id}`, {
headers: authHeaders()
});
if(fetchLatest.ok) {
const latestJob = await fetchLatest.json();
const currentNotes = latestJob.Notes || '';
const updatedNotes = formatted + '\n\n' + currentNotes;
const resJob = await fetch(`${PB_COLLECTION_URL}/${job.id}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json', ...authHeaders()},
body: JSON.stringify({Notes: updatedNotes})
});
if(!resJob.ok) {
console.error('Job_Info update failed:', resJob.status);
throw new Error('Failed to update Job_Info: ' + resJob.status);
}
const updated = await resJob.json();
jobRecordId = updated.id || job.id;
// Update both current job object and the passed job parameter
currentJob.Notes = updatedNotes;
job.Notes = updatedNotes;
console.log('Updated Job_Info_Prod.Notes for job:', jobRecordId);
} else {
throw new Error('Failed to fetch latest job data: ' + fetchLatest.status);
}
}
// Create payload - only include job_info_id, not Job_Number_Id array
const payloadNote = {
Note: formatted.trim(),
Job_Number: job?.Job_Number || '',
job_info_id: jobRecordId,
type: noteType
};
console.log('Submitting note payload:', payloadNote);
// Create note in notes collection with type
const resNote = await fetch(`${NOTES_COLLECTION_URL}`, {
method: 'POST',
headers: {'Content-Type': 'application/json', ...authHeaders()},
body: JSON.stringify(payloadNote)
});
if(!resNote.ok) {
const errText = await resNote.text().catch(() => '');
console.error('Note creation failed:', resNote.status, errText);
// Don't throw if Notes collection fails but Job_Info succeeded
if(!isManagerNote) {
console.warn('Note saved to Job_Info but failed to save to Notes collection');
} else {
throw new Error('Failed to save note: ' + resNote.status);
}
} else {
const savedNote = await resNote.json();
console.log('Note created successfully in Notes collection:', savedNote);
}
// Clear editor
editor.innerHTML = '';
alert('Note saved successfully!');
// Refresh the display
if(currentJob) {
await loadAndDisplayNotes(currentJob);
}
} catch(err) {
console.error('Error saving note:', err);
alert('Failed to save note: ' + err.message);
}
}
// Load and display notes filtered by type
async function loadAndDisplayNotes(job) {
if(!job?.id) return;
try {
// Fetch all notes for this job using job_info_id
const filter = `job_info_id="${job.id}"`;
const resp = await fetch(`${NOTES_COLLECTION_URL}?filter=${encodeURIComponent(filter)}&sort=-created`, {
headers: authHeaders()
});
if(!resp.ok) {
console.error('Failed to fetch notes:', resp.status);
return;
}
const data = await resp.json();
const allNotes = data.items || [];
// Filter notes by type
const jobNotes = allNotes.filter(n => !n.type || n.type === 'job');
const managerNotes = allNotes.filter(n => n.type === 'manager');
// Update job notes display using ID selector
const jobNotesDisplay = document.getElementById('jobNotesDisplay');
if(jobNotesDisplay) {
if(jobNotes.length > 0) {
jobNotesDisplay.innerHTML = jobNotes.map(n => n.Note).join('\n\n');
} else {
jobNotesDisplay.innerHTML = '<span class="text-gray-400 italic">No notes available</span>';
}
}
// Update manager notes display using ID selector
const managerNotesDisplay = document.getElementById('managerNotesDisplay');
if(managerNotesDisplay) {
if(managerNotes.length > 0) {
managerNotesDisplay.innerHTML = managerNotes.map(n => n.Note).join('\n\n');
} else {
managerNotesDisplay.innerHTML = '<span class="text-gray-400 italic">No manager notes available</span>';
}
}
console.log('Loaded notes - Job:', jobNotes.length, 'Manager:', managerNotes.length);
} catch(err) {
console.error('Error loading notes:', err);
}
}
// Function to populate file columns in notes section
function populateNotesFileColumns(files, containerId) {
const container = document.getElementById(containerId);
if(!container) {
console.log('Container not found:', containerId);
return;
}
console.log('Populating', containerId, 'with', files.length, 'files');
container.innerHTML = '';
// Categorize files
const plans = [];
const estimates = [];
const otherFiles = [];
if(files && files.length > 0) {
files.forEach(file => {
const name = file.name.toLowerCase();
if(name.includes('plan') || name.includes('drawing') || name.includes('dwg')) {
plans.push(file);
} else if(name.includes('estimate') || name.includes('est')) {
estimates.push(file);
} else {
otherFiles.push(file);
}
});
console.log('Categorized - Plans:', plans.length, 'Estimates:', estimates.length, 'Other:', otherFiles.length);
}
// Plans column
const plansCol = document.createElement('div');
plansCol.innerHTML = `<div class="font-bold text-gray-700 uppercase tracking-wide mb-1" style="font-size: 8px;">Plans</div>`;
const plansList = document.createElement('div');
plansList.className = 'space-y-1';
if(plans.length > 0) {
plans.forEach(file => {
const fileItem = document.createElement('div');
fileItem.className = 'flex items-center gap-1 text-blue-600 hover:text-blue-800 cursor-pointer';
fileItem.style.fontSize = '10px';
// File icon using getFileIcon
const iconWrapper = document.createElement('span');
iconWrapper.className = 'flex-shrink-0';
iconWrapper.innerHTML = getFileIcon(file.name, file.contentType);
fileItem.appendChild(iconWrapper);
const fileName = document.createElement('span');
fileName.textContent = file.name;
fileName.className = 'truncate';
fileItem.appendChild(fileName);
fileItem.onclick = () => openFilePreview(file.driveId || '', file.id || '', file.name, file.contentType || '', file.url);
plansList.appendChild(fileItem);
});
} else {
plansList.innerHTML = `<div class="text-gray-400 italic" style="font-size: 10px;">No plans</div>`;
}
plansCol.appendChild(plansList);
// Estimates column
const estimatesCol = document.createElement('div');
estimatesCol.innerHTML = `<div class="font-bold text-gray-700 uppercase tracking-wide mb-1" style="font-size: 8px;">Estimates</div>`;
const estimatesList = document.createElement('div');
estimatesList.className = 'space-y-1';
if(estimates.length > 0) {
estimates.forEach(file => {
const fileItem = document.createElement('div');
fileItem.className = 'flex items-center gap-1 text-blue-600 hover:text-blue-800 cursor-pointer';
fileItem.style.fontSize = '10px';
// File icon using getFileIcon
const iconWrapper = document.createElement('span');
iconWrapper.className = 'flex-shrink-0';
iconWrapper.innerHTML = getFileIcon(file.name, file.contentType);
fileItem.appendChild(iconWrapper);
const fileName = document.createElement('span');
fileName.textContent = file.name;
fileName.className = 'truncate';
fileItem.appendChild(fileName);
fileItem.onclick = () => openFilePreview(file.driveId || '', file.id || '', file.name, file.contentType || '', file.url);
estimatesList.appendChild(fileItem);
});
} else {
estimatesList.innerHTML = `<div class="text-gray-400 italic" style="font-size: 10px;">No estimates</div>`;
}
estimatesCol.appendChild(estimatesList);
// Other Files column
const otherCol = document.createElement('div');
otherCol.innerHTML = `<div class="font-bold text-gray-700 uppercase tracking-wide mb-1" style="font-size: 8px;">Other Files</div>`;
const otherList = document.createElement('div');
otherList.className = 'space-y-1';
if(otherFiles.length > 0) {
otherFiles.forEach(file => {
const fileItem = document.createElement('div');
fileItem.className = 'flex items-center gap-1 text-blue-600 hover:text-blue-800 cursor-pointer';
fileItem.style.fontSize = '10px';
// File icon using getFileIcon
const iconWrapper = document.createElement('span');
iconWrapper.className = 'flex-shrink-0';
iconWrapper.innerHTML = getFileIcon(file.name, file.contentType);
fileItem.appendChild(iconWrapper);
const fileName = document.createElement('span');
fileName.textContent = file.name;
fileName.className = 'truncate';
fileItem.appendChild(fileName);
fileItem.onclick = () => openFilePreview(file.driveId || '', file.id || '', file.name, file.contentType || '', file.url);
otherList.appendChild(fileItem);
});
} else {
otherList.innerHTML = `<div class="text-gray-400 italic" style="font-size: 10px;">No other files</div>`;
}
otherCol.appendChild(otherList);
container.appendChild(plansCol);
container.appendChild(estimatesCol);
container.appendChild(otherCol);
}
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 managerInfoTab = document.getElementById('managerInfoTab');
const infoContent = document.getElementById('infoContent');
const notesContent = document.getElementById('notesContent');
const managerInfoContent = document.getElementById('managerInfoContent');
const infoHeading = document.getElementById('infoHeading');
const notesHeading = document.getElementById('notesHeading');
const managerInfoHeading = document.getElementById('managerInfoHeading');
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';
managerInfoTab.className = 'px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700';
infoContent.classList.remove('hidden');
notesContent.classList.add('hidden');
managerInfoContent.classList.add('hidden');
if(infoHeading) infoHeading.classList.remove('hidden');
if(notesHeading) notesHeading.classList.add('hidden');
if(managerInfoHeading) managerInfoHeading.classList.add('hidden');
} else if(tab === 'notes') {
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';
managerInfoTab.className = 'px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700';
infoContent.classList.add('hidden');
notesContent.classList.remove('hidden');
managerInfoContent.classList.add('hidden');
if(infoHeading) infoHeading.classList.add('hidden');
if(notesHeading) notesHeading.classList.remove('hidden');
if(managerInfoHeading) managerInfoHeading.classList.add('hidden');
} else if(tab === 'managerInfo') {
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-gray-500 hover:text-gray-700';
managerInfoTab.className = 'px-4 py-2 text-sm font-medium text-green-600 border-b-2 border-green-600 -mb-px';
infoContent.classList.add('hidden');
notesContent.classList.add('hidden');
managerInfoContent.classList.remove('hidden');
if(infoHeading) infoHeading.classList.add('hidden');
if(notesHeading) notesHeading.classList.add('hidden');
if(managerInfoHeading) managerInfoHeading.classList.remove('hidden');
}
}
document.getElementById('infoTab').addEventListener('click',()=>switchTab('info'));
document.getElementById('notesTab').addEventListener('click',()=>switchTab('notes'));
document.getElementById('managerInfoTab').addEventListener('click',()=>{
switchTab('managerInfo');
populateManagerInfo(currentJob);
});
// --- 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 = {
managerInfo: ['manager info', 'manager_info', 'managerinfo'],
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: '', jobName: '' };
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 pdfControls = document.getElementById('pdfControls');
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.managerInfo.some((w) => n.includes(w))) return 'managerInfo';
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-3 h-3" 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>';
// Show Word icon with PDF badge to indicate it will be converted
if (['doc', 'docx'].includes(ext) || type.includes('word'))
return '<svg class="w-3 h-3" 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-3 h-3" 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-3 h-3" 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-3 h-3" 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-3 h-3" 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-3 h-3" 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-3 h-3" 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();
// Allow Word docs to be previewed (will be converted to PDF)
return ext === 'pdf' || type.includes('pdf') ||
['doc', 'docx'].includes(ext) || type.includes('word') ||
['jpg', 'jpeg', 'png', 'gif', 'svg', 'bmp'].includes(ext) || type.includes('image');
};
function openFileInViewer(url, name) {
const jobPrefix = fileListState.jobNumber ? `Job# ${fileListState.jobNumber}${fileListState.jobName ? ' - ' + fileListState.jobName : ''} - ` : '';
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, isImage } = currentPdf;
if (isImage) {
renderImageOnCanvas();
return;
}
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 renderImageOnCanvas() {
const { scale, imageElement, imageWidth, imageHeight } = currentPdf;
if (!imageElement) return;
const canvas = pdfCanvas;
const context = canvas.getContext('2d');
const scaledWidth = imageWidth * scale;
const scaledHeight = imageHeight * scale;
canvas.width = scaledWidth;
canvas.height = scaledHeight;
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(imageElement, 0, 0, scaledWidth, scaledHeight);
// re-apply pan transform after render
pdfCanvas.style.transform = `translate(${pdfPan.x}px, ${pdfPan.y}px)`;
pdfZoomLabel.textContent = `${Math.round(scale * 100)}%`;
pdfZoomReset.textContent = `${Math.round(scale * 100)}%`;
}
function showPdfViewer() {
iframeViewer.classList.add('hidden');
pdfViewer.classList.remove('hidden');
iframeLoader.classList.add('hidden');
// Ensure PDF controls are visible (might be hidden for images)
pdfControls.style.display = '';
// Show page navigation for PDFs
pdfPrev.parentElement.style.display = '';
pdfNext.parentElement.style.display = '';
pdfPageNum.parentElement.style.display = '';
}
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 && !currentPdf.isImage) return;
currentPdf.scale = Math.min(currentPdf.scale + 0.1, MAX_PDF_SCALE);
await renderPdfPage();
});
pdfZoomOut.addEventListener('click', async () => {
if (!currentPdf.doc && !currentPdf.isImage) return;
currentPdf.scale = Math.max(currentPdf.scale - 0.1, MIN_PDF_SCALE);
await renderPdfPage();
});
pdfZoomReset.addEventListener('click', async () => {
if (!currentPdf.doc && !currentPdf.isImage) return;
currentPdf.scale = currentPdf.isImage ? 1 : 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 && !currentPdf.isImage) 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 && !currentPdf.isImage) 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 && !currentPdf.isImage) 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}${fileListState.jobName ? ' - ' + fileListState.jobName : ''} - ` : '';
iframeTitle.textContent = `${jobPrefix}${name || 'File Preview'}`;
fileListContainer.classList.add('hidden');
iframeViewer.classList.add('hidden');
pdfViewer.classList.add('hidden');
iframeLoader.classList.remove('hidden');
// Detect if this is a Word document
const ext = (name || '').toLowerCase().split('.').pop();
const isWordDoc = ['doc', 'docx'].includes(ext) || (contentType || '').toLowerCase().includes('word');
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">${isWordDoc ? 'Converting to PDF...' : 'Loading preview...'}</div>
</div>
`;
// Clear prior blob URL if any
if (currentPreviewUrl) {
URL.revokeObjectURL(currentPreviewUrl);
currentPreviewUrl = null;
}
try {
// Use PDF conversion endpoint for Word documents
const endpoint = isWordDoc
? `/api/job-file-pdf?driveId=${encodeURIComponent(driveId)}&itemId=${encodeURIComponent(itemId)}`
: `/api/job-file-content?driveId=${encodeURIComponent(driveId)}&itemId=${encodeURIComponent(itemId)}`;
const res = await fetch(endpoint, {
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);
// Word docs are converted to PDF, so treat them as PDFs
const isPdf = isWordDoc || (contentType || '').toLowerCase().includes('pdf') || (name || '').toLowerCase().endsWith('.pdf');
const isImage = (contentType || '').toLowerCase().includes('image') ||
/\.(jpg|jpeg|png|gif|bmp|tiff|webp)$/i.test(name || '');
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;
const initialScale = isWordDoc ? 1.4 : DEFAULT_PDF_SCALE;
currentPdf = { doc, page: 1, pages: doc.numPages, scale: initialScale };
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 if (isImage) {
// Show images in the PDF viewer area for consistency
pdfViewer.classList.remove('hidden');
iframeViewer.classList.add('hidden');
iframeLoader.classList.add('hidden');
// Store original image for re-rendering at different scales
const img = new Image();
img.onload = () => {
currentPdf = {
doc: null,
page: 1,
pages: 1,
scale: 1,
isImage: true,
imageElement: img,
imageWidth: img.width,
imageHeight: img.height
};
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
pdfCanvas.style.transform = 'translate(0px, 0px)';
renderImageOnCanvas();
// Show PDF controls (zoom works for images too)
pdfControls.style.display = '';
// Hide page navigation for images
pdfPrev.parentElement.style.display = 'none';
pdfNext.parentElement.style.display = 'none';
pdfPageNum.parentElement.style.display = 'none';
};
img.src = currentPreviewUrl;
} else {
// fallback to iframe blob view
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) => {
// Show PDF files, Word documents (which will be converted to PDF), and images
if (f.isFolder) return false;
const name = f.name.toLowerCase();
const contentType = (f.contentType || '').toLowerCase();
const isPdfOrWord = name.endsWith('.pdf') || contentType.includes('pdf') ||
name.endsWith('.doc') || name.endsWith('.docx') || contentType.includes('word');
const isImage = name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.png') ||
name.endsWith('.gif') || name.endsWith('.bmp') || name.endsWith('.tiff') ||
name.endsWith('.webp') || contentType.includes('image');
if (!isPdfOrWord && !isImage) return false;
// Apply search term filter if present
return name.includes(term);
});
const groups = { managerInfo: [], 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('Manager Info', groups.managerInfo)}
${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 || '';
fileListState.jobName = job.Job_Full_Name || '';
// Show modal in list mode
iframeViewerModal.classList.remove('hidden');
iframeTitle.textContent = job.Job_Number ? `Job# ${job.Job_Number}${job.Job_Full_Name ? ' - ' + job.Job_Full_Name : ''}` : '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;
iframeLoader.classList.add('hidden');
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(), 30000); // 30 seconds for large folders
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;
console.log(`✓ Loaded ${items.length} files for job ${job.Job_Number}`);
iframeLoader.classList.add('hidden');
renderFileGroups(link);
} catch (err) {
console.error('❌ job-files error for', job.Job_Number, 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 file cache when closing to ensure fresh data on next open
fileCache.clear();
// 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);
// Proactively refresh Graph token on load
console.log('Init: refreshing Graph token');
await refreshGraphToken();
// Set up background token refresh every 50 minutes (tokens typically expire after 60 min)
setInterval(async () => {
console.log('Background: refreshing Graph token');
await refreshGraphToken();
}, 50 * 60 * 1000); // 50 minutes
console.log('Init: about to fetch jobs');
fetchAllJobs();
})();
</script>
</body>
</html>