Files
Job-Info/frontend/index.html
T
2025-12-17 23:19:53 +00:00

770 lines
43 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="auth.js"></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-white border border-gray-200 rounded-lg px-3 py-1.5 mb-3 shadow-sm flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="h-9 w-[186px] rounded bg-black flex items-center justify-center overflow-hidden">
<img src="assets/CCwhiteApp.png" alt="CC" class="h-9 w-auto object-cover scale-105" loading="lazy">
</div>
</div>
<div class="flex items-center gap-2">
<a id="ideaLink" href="#" class="inline-flex items-center justify-center h-8 w-8 rounded-full bg-amber-50 border border-amber-200 text-amber-600 hover:bg-amber-100" title="Submit an idea">
<img src="assets/lightbulb.svg" alt="Idea" class="h-4 w-4" loading="lazy">
</a>
<div class="h-9 w-12 rounded-lg overflow-hidden flex items-center justify-center bg-white">
<img src="assets/PalmIsland.png" alt="Palm Island" class="h-full w-auto object-cover" loading="lazy">
</div>
</div>
</div>
<h1 class="m-0 mb-3 text-center text-blue-600 text-[33px] font-bold">Job Info</h1>
<div class="w-full bg-blue-50 h-1.5 rounded overflow-hidden mb-3">
<div id="progressBar" class="h-full w-0 bg-blue-600 transition-all duration-300 ease-out"></div>
</div>
<input id="searchBox" placeholder="Search jobs (just start typing)" class="w-full p-2.5 rounded-lg border border-gray-300 mb-2 text-lg placeholder:text-gray-400">
<button id="filterToggle" class="bg-blue-600 text-white py-2 px-2.5 rounded-lg border-none cursor-pointer mb-2 inline-block min-w-[140px]">Show Filters</button>
<div id="filtersPanel" class="hidden bg-white border border-gray-200 rounded-lg p-2.5 mb-2.5">
<div class="mb-2 text-sm">
<strong>Division</strong><br/>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-division" value="C#"> Commercial</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-division" value="R#"> Residential</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-division" value="S#"> Small Jobs</label>
</div>
<div class="mb-2 text-sm">
<strong>Active</strong><br/>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-active" value="Yes"> Yes</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-active" value="No"> No</label>
</div>
<div class="mb-2 text-sm">
<strong>Estimator</strong><br/>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-estimator" value="Steve Brewer"> Steve</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-estimator" value="Beth Cardoza"> Beth</label>
</div>
<div class="mb-2 text-sm">
<strong>Status</strong><br/>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Estimating"> Estimating</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="In Progress"> In Progress</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Awarded"> Awarded</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Billed (In Progress)"> Billed (In Progress)</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Not Bidding"> Not Bidding</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Est Sent"> Est Sent</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Not Awarded"> Not Awarded</label>
</div>
<button id="clearFilters" class="bg-gray-500 text-white py-2 px-2 rounded-lg border-none">Clear Filters</button>
</div>
<div id="results" aria-live="polite" class="flex flex-col gap-3 flex-1 overflow-auto p-0"></div>
<div class="text-xs text-gray-500 mt-5 pt-3 border-t border-gray-200 flex items-center justify-between">
<span id="userEmail" class="truncate"></span>
<span id="versionLabel">v1.0.0-beta2</span>
</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>
<script>
(async function() {
const { pb, authHeaders, ensureAuth, getDisplayName, getEmail } = window.Auth;
const authed = await ensureAuth();
if (!authed) return;
// --- Utility ---
const fetchNoCache = (url, options={}) => {
const headers = { ...(options.headers||{}), ...authHeaders() };
return fetch(url + `&_ts=${Date.now()}`, { ...options, headers });
};
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 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;
// Load version from backend /version endpoint so UI matches package.json
async function loadVersion(){
if(!versionLabel) return;
try{
const res = await fetch('/version');
if(!res.ok) {
console.warn(`Version endpoint returned ${res.status}. Using default version.`);
return;
}
const data = await res.json().catch(()=>null);
if(data?.version) versionLabel.textContent = `v${data.version}`;
}catch(err){
console.debug('Version load failed (non-critical):', err.message);
}
}
loadVersion();
let progTimer=null;
function startProgress(){
progressBar.style.width='0%';
if(progTimer) clearInterval(progTimer);
let simulatedProg=0;
progTimer=setInterval(()=>{
if(simulatedProg<65){ simulatedProg+=0.8; progressBar.style.width=Math.min(65,simulatedProg).toFixed(1)+'%'; }
},150);
}
function finishProgress(){ if(progTimer) clearInterval(progTimer); progressBar.style.width='100%'; setTimeout(()=>{progressBar.parentElement.classList.add('hidden');},400); }
// --- Fetch all jobs ---
async function fetchAllJobs(){
startProgress();
clearJobsCache('Loading…');
let page=1, totalItems=0;
try{
while(true){
const url = `${PB_COLLECTION_URL}?page=${page}&perPage=${perPage}&sort=-Job_Number`;
const res = await fetchNoCache(url);
if(!res.ok) throw new Error(`Fetch failed: ${res.status}`);
const data = await res.json();
const items = data.items || [];
jobsCache.push(...items);
totalItems = data.totalItems || (items.length + ((page-1)*perPage));
// Apply filters on the currently loaded pages so initial results reflect preferences immediately.
applyFiltersAndRender();
if(progTimer) clearInterval(progTimer);
const realProg=Math.min(95, Math.floor((jobsCache.length / Math.max(1,totalItems)) * 95));
progressBar.style.width=realProg+'%';
if(jobsCache.length >= totalItems || items.length===0) break;
page++;
}
finishProgress();
}catch(err){ finishProgress(); alert('Failed to fetch jobs: '+err.message); console.error(err);}
}
function renderInitialBatch(jobs){
resultsEl.innerHTML='';
for(const job of jobs){
const card=document.createElement('div'); card.className='bg-white rounded-lg p-2.5 border border-blue-100 shadow-sm cursor-pointer relative flex flex-col justify-center gap-1';
card.innerHTML=`
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job #:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Job_Number||'')}</div></div>
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job Name:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Job_Full_Name||'')}</div></div>
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Contact:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Contact_Person||'')}</div></div>
`;
const dot=document.createElement('div'); dot.className='absolute top-2.5 right-2.5 w-3.5 h-3.5 rounded-full border-2 border-white shadow-sm';
{
const b = toBool(job.Active);
dot.style.background = b===true ? 'green' : (b===false ? 'red' : '#cbd5e1');
}
card.appendChild(dot);
card.addEventListener('click',()=>openDetail(job));
resultsEl.appendChild(card);
}
}
function jobNumberValue(job){
const raw = String(job.Job_Number||'');
const match = raw.match(/[0-9]+\.?[0-9]*/);
if(!match) return null;
const num = parseFloat(match[0]);
return isNaN(num) ? null : num;
}
function sortJobsDescending(jobs){
return jobs.sort((a,b)=>{
const na = jobNumberValue(a);
const nb = jobNumberValue(b);
if(nb!==null && na!==null){ return nb - na; }
if(nb!==null) return 1;
if(na!==null) return -1;
return String(b.Job_Number||'').localeCompare(String(a.Job_Number||''));
});
}
function applyFiltersAndRender(){
const q = safeLower(searchBox.value||'');
const divisionVals = Array.from(document.querySelectorAll('.filter-division:checked')).map(i=>i.value);
const activeVals = Array.from(document.querySelectorAll('.filter-active:checked')).map(i=>i.value);
const activeWanted = activeVals.map(v=>toBool(v)).filter(v=>v!==null);
const statusVals = Array.from(document.querySelectorAll('.filter-status:checked')).map(i=>i.value);
const estimatorVals = Array.from(document.querySelectorAll('.filter-estimator:checked')).map(i=>i.value);
const estimatorValsLower = estimatorVals.map(s=>safeLower(s));
visibleJobs = jobsCache.filter(job=>{
const searchable = [job.Job_Number, job.Job_Full_Name, job.Job_Address, job.Contact_Person, job.Company_Client, job.Notes].join(' ').toLowerCase();
if(q && !searchable.includes(q)) return false;
if(divisionVals.length && !divisionVals.includes(job.Job_Division)) return false;
if(activeWanted.length){
const b = toBool(job.Active);
if(b===null || !activeWanted.includes(b)) return false;
}
if(statusVals.length && !statusVals.includes(job.Job_Status||'')) return false;
if(estimatorVals.length){
const je = safeLower(job.Estimator||'');
const ok = estimatorValsLower.some(v=>je.includes(v) || v.includes(je));
if(!ok) return false;
}
return true;
});
// Data already sorted from PocketBase, only re-sort if filters applied
if(q || divisionVals.length || activeVals.length || statusVals.length || estimatorVals.length){
sortJobsDescending(visibleJobs);
}
console.log('Filter summary', { q, divisionVals, activeWanted, statusVals, estimatorValsLower, total: jobsCache.length, visible: visibleJobs.length });
renderCards(visibleJobs);
}
function renderCards(list){
resultsEl.innerHTML='';
if(!list.length){ resultsEl.innerHTML = `<div class="p-2 text-slate-600 bg-white rounded-lg border border-blue-100">No results</div>`; return; }
const firstChunk = list.slice(0,20);
const remaining = list.slice(20);
for(const job of firstChunk){
const card=document.createElement('div'); card.className='bg-white rounded-lg p-2.5 border border-blue-100 shadow-sm cursor-pointer relative flex flex-col justify-center gap-1';
card.innerHTML=`
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job #:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Job_Number||'')}</div></div>
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job Name:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Job_Full_Name||'')}</div></div>
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Contact:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Contact_Person||'')}</div></div>
`;
const dot=document.createElement('div'); dot.className='absolute top-2.5 right-2.5 w-3.5 h-3.5 rounded-full border-2 border-white shadow-sm';
{
const b = toBool(job.Active);
dot.style.background = b===true ? 'green' : (b===false ? 'red' : '#cbd5e1');
}
card.appendChild(dot);
card.addEventListener('click',()=>openDetail(job));
resultsEl.appendChild(card);
}
if(remaining.length){
setTimeout(()=>{
for(const job of remaining){
const card=document.createElement('div'); card.className='bg-white rounded-lg p-2.5 border border-blue-100 shadow-sm cursor-pointer relative flex flex-col justify-center gap-1';
card.innerHTML=`
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job #:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Job_Number||'')}</div></div>
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job Name:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Job_Full_Name||'')}</div></div>
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Contact:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(job.Contact_Person||'')}</div></div>
`;
const dot=document.createElement('div'); dot.className='absolute top-2.5 right-2.5 w-3.5 h-3.5 rounded-full border-2 border-white shadow-sm';
{
const b = toBool(job.Active);
dot.style.background = b===true ? 'green' : (b===false ? 'red' : '#cbd5e1');
}
card.appendChild(dot);
card.addEventListener('click',()=>openDetail(job));
resultsEl.appendChild(card);
}
}, 50);
}
}
function escapeHtml(str){ return String(str).replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;'); }
// --- Filters ---
filterToggle.addEventListener('click',()=>{ filtersPanel.classList.toggle('hidden'); filterToggle.textContent = filtersPanel.classList.contains('hidden')?'Show Filters':'Hide Filters'; });
searchBox.addEventListener('input',()=>applyFiltersAndRender());
document.querySelectorAll('.filter-division').forEach(cb=>cb.addEventListener('change',()=>applyFiltersAndRender()));
document.querySelectorAll('.filter-active').forEach(cb=>{ cb.addEventListener('change',()=>{
if(cb.checked) document.querySelectorAll('.filter-active').forEach(o=>{if(o!==cb)o.checked=false;}); applyFiltersAndRender();
});});
document.querySelectorAll('.filter-estimator').forEach(cb=>{ cb.addEventListener('change',()=>{
if(cb.checked) document.querySelectorAll('.filter-estimator').forEach(o=>{if(o!==cb)o.checked=false;}); applyFiltersAndRender();
});});
document.querySelectorAll('.filter-status').forEach(cb=>cb.addEventListener('change',()=>applyFiltersAndRender()));
document.getElementById('clearFilters').addEventListener('click',()=>{
document.querySelectorAll('.filter-division, .filter-active, .filter-status, .filter-estimator').forEach(cb=>cb.checked=false);
applyFiltersAndRender();
});
// --- Preferences ---
async function loadUserPreferences(){
console.log('loadUserPreferences: email=', currentUserEmail);
if(!currentUserEmail) return null;
try{
const list = await pb.collection('user_preferences').getList(1,1,{
filter: `user_email="${currentUserEmail}"`
});
const record = list?.items?.[0];
const prefs = record?.job_info_prefs || record?.prefs || null;
console.log('loadUserPreferences: fetched prefs=', prefs);
if(!prefs && currentUserEmail.toLowerCase()==='rcardoza@cardoza.construction'){
console.log('loadUserPreferences: using fallback for rcardoza');
return { filters: { active:true, estimator:'Steve Brewer', job_division:'C#' } };
}
return prefs;
}catch(err){
console.warn('loadUserPreferences: error=', err);
const isTarget = currentUserEmail && currentUserEmail.toLowerCase()==='rcardoza@cardoza.construction';
if(isTarget){
console.log('loadUserPreferences: using error fallback for rcardoza');
return { filters: { active:true, estimator:'Steve Brewer', job_division:'C#' } };
}
return null;
}
}
function applyPrefsToFilters(prefs){
console.log('applyPrefsToFilters: received prefs=', prefs);
// Handle both direct filters and nested prefs.filters
const filters = prefs?.filters || prefs?.prefs?.filters;
if(!filters) {
console.log('applyPrefsToFilters: no filters found, returning');
return;
}
const { active, estimator, job_division, status } = filters;
console.log('applyPrefsToFilters: applying filters', filters);
// reset all filters first
document.querySelectorAll('.filter-division, .filter-active, .filter-status, .filter-estimator').forEach(cb=>cb.checked=false);
if(typeof active === 'boolean'){
document.querySelectorAll('.filter-active').forEach(cb=>{
const v = cb.value.trim().toLowerCase();
if((active && v==='yes') || (!active && v==='no')) cb.checked=true;
});
}
if(estimator){
const estLower = String(estimator||'').trim().toLowerCase();
document.querySelectorAll('.filter-estimator').forEach(cb=>{
const cbVal = cb.value.trim().toLowerCase();
if(cbVal === estLower || cbVal.includes(estLower) || estLower.includes(cbVal)) cb.checked=true;
});
// If no estimator got checked, try a direct match to 'steve brewer'
const anyChecked = Array.from(document.querySelectorAll('.filter-estimator')).some(cb=>cb.checked);
if(!anyChecked && estLower.startsWith('steve')){
document.querySelectorAll('.filter-estimator').forEach(cb=>{
if(cb.value.trim().toLowerCase().includes('steve')) cb.checked=true;
});
}
}
if(job_division){
const jd = String(job_division||'').trim().toLowerCase();
let code=null;
if(jd.includes('c#') || jd.includes('commercial')) code='C#';
else if(jd.includes('r#') || jd.includes('residential')) code='R#';
else if(jd.includes('s#') || jd.includes('small')) code='S#';
if(code){
document.querySelectorAll('.filter-division').forEach(cb=>{ if(cb.value===code) cb.checked=true; });
}
}
if(status){
const arr = Array.isArray(status) ? status : [status];
const lowerStatuses = arr.map(s=>String(s||'').trim().toLowerCase());
document.querySelectorAll('.filter-status').forEach(cb=>{
if(lowerStatuses.includes(cb.value.trim().toLowerCase())) cb.checked=true;
});
}
applyFiltersAndRender();
}
// --- Modal & Notes ---
function openDetail(job){
currentJob = job;
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","Job_Division"];
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;
}
// 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=()=>window.open(url.href, '_blank', 'noopener,noreferrer');
}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'));
detailModal.classList.remove('hidden');
document.getElementById('detailBox').scrollTop = 0;
// Reset to INFO tab when opening
switchTab('info');
}
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='');
// --- Init ---
const prefs = await loadUserPreferences();
console.log('Init: loaded prefs=', prefs);
applyPrefsToFilters(prefs);
console.log('Init: about to fetch jobs');
fetchAllJobs();
})();
</script>
</body>
</html>