Files
Job-Info/frontend/index.html
T
2025-12-16 09:23:38 -06:00

571 lines
34 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>
<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">
<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-right text-xs text-gray-500 mt-5 pt-3 border-t border-gray-200"><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-[90vh] 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 } = 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 perPage = 50;
let jobsCache = [], visibleJobs = [], currentJob = null;
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 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();
// 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) return;
const data = await res.json().catch(()=>null);
if(data?.version) versionLabel.textContent = `v${data.version}`;
}catch(err){ /* silent */ }
}
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(); jobsCache=[]; visibleJobs=[]; resultsEl.innerHTML = '<div class="p-2 text-slate-600 bg-white rounded-lg border border-blue-100">Loading…</div>';
let page=1, totalItems=0; let firstBatchRendered=false;
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));
// Render first 40 immediately
if(!firstBatchRendered && jobsCache.length >= 40){
firstBatchRendered=true;
renderInitialBatch(jobsCache.slice(0,40));
}
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();
applyFiltersAndRender();
}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';
dot.style.background=(String(job.Active||'').toLowerCase()==='yes')?'green':((String(job.Active||'').toLowerCase()==='no')?'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 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);
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(activeVals.length && !activeVals.includes(job.Active)) return false;
if(statusVals.length && !statusVals.includes(job.Job_Status||'')) return false;
if(estimatorVals.length && !estimatorVals.includes(job.Estimator||'')) 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);
}
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';
dot.style.background=(String(job.Active||'').toLowerCase()==='yes')?'green':((String(job.Active||'').toLowerCase()==='no')?'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';
dot.style.background=(String(job.Active||'').toLowerCase()==='yes')?'green':((String(job.Active||'').toLowerCase()==='no')?'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();
});
// --- 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"];
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-500 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-500 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-500 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-500 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;
}
// 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-500 uppercase tracking-wide mb-1'; label.textContent=f.replace(/_/g,' ');
const val = document.createElement('div'); val.className='text-sm text-gray-900';
// 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');
}
document.getElementById('btnClose').addEventListener('click',()=>{detailModal.classList.add('hidden');});
document.getElementById('backBtn').addEventListener('click',()=>{detailModal.classList.add('hidden');});
// 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 || '', // text value
Job_Number_Id: currentJob?.id || '' // id value
};
const userId = pb.authStore?.record?.id || pb.authStore?.model?.id;
if (userId) payloadNote.user = userId;
const [resJob, resNote] = await Promise.all([
fetch(`${PB_COLLECTION_URL}/${currentJob.id}`,{
method:'PATCH',
headers:{'Content-Type':'application/json', ...authHeaders()},
body:JSON.stringify({Notes:updatedNotes})
}),
fetch(`${NOTES_COLLECTION_URL}`,{
method:'POST',
headers:{'Content-Type':'application/json', ...authHeaders()},
body:JSON.stringify(payloadNote)
})
]);
if(!resJob.ok) throw new Error('Job_Info update failed: '+resJob.status);
if(!resNote.ok){
const errText = await resNote.text().catch(()=> '');
throw new Error('Notes create failed: '+resNote.status+' '+errText);
}
const updated=await resJob.json();
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='';
fetch('/log-change',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'update-notes',user:currentUserName,target:currentJob.id,detail:'Job_Info notes updated + notes record created'})}).catch(()=>{});
applyFiltersAndRender();
}catch(err){alert('Error saving note: '+err.message); console.error(err);}
});
document.getElementById('btnClearNote').addEventListener('click',()=>newNote.innerHTML='');
// --- Init ---
fetchAllJobs();
})();
</script>
</body>
</html>