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