1// patch.js - Runtime fixes for Jamail Hardwoods Portal 2// This file is loaded after app.js to fix known issues 3 4(function() { 5 'use strict'; 6 7 // === LIGHT MODE ENFORCEMENT === 8 // Ensure light mode is always enabled 9 if (document && document.documentElement && !document.documentElement.classList.contains('light')) { 10 document.documentElement.classList.add('light'); 11 } 12 13 // Override toggleTheme to be a no-op (prevents any dark mode switching) 14 window.toggleTheme = function() { 15 // Theme switching disabled - light mode only 16 return false; 17 }; 18 19 // === FIX 1: Increase product catalog search limit from 50 to 500 === 20 if (typeof searchProductCatalog === 'function') { 21 const _origSearch = searchProductCatalog; 22 window.searchProductCatalog = function(query) { 23 if (!query && typeof _productCatalogCache !== 'undefined' && _productCatalogCache) { 24 return _productCatalogCache.slice(0, 500); 25 } 26 const results = _origSearch(query); 27 return results; 28 }; 29 } 30 31 // === FIX 2: Override saveVendor to persist to Supabase === 32 const _origSaveVendor = typeof saveVendor === 'function' ? saveVendor : null; 33 if (_origSaveVendor) { 34 window.saveVendor = function(editIdx) { 35 const name = document.getElementById('vnd-f-name')?.value?.trim(); 36 const address = document.getElementById('vnd-f-address')?.value?.trim() || ''; 37 const acctNum = document.getElementById('vnd-f-acct')?.value?.trim() || ''; 38 const phone = document.getElementById('vnd-f-phone')?.value?.trim() || ''; 39 const freight = document.getElementById('vnd-f-freight')?.value?.trim() || ''; 40 const website = document.getElementById('vnd-f-website')?.value?.trim() || ''; 41 const notes = document.getElementById('vnd-f-notes')?.value?.trim() || ''; 42 const productsStr = document.getElementById('vnd-f-products')?.value?.trim() || ''; 43 const repName = document.getElementById('vnd-f-rep-name')?.value?.trim() || ''; 44 const repEmail = document.getElementById('vnd-f-rep-email')?.value?.trim() || ''; 45 const repPhone = document.getElementById('vnd-f-rep-phone')?.value?.trim() || ''; 46 const products = productsStr ? productsStr.split(',').map(p => p.trim()).filter(Boolean) : []; 47 _origSaveVendor(editIdx); 48 if (!name) return; 49 try { 50 const dbEntry = { 51 name, address, account_number: acctNum, phone, freight, 52 products, rep_name: repName, rep_email: repEmail, 53 rep_phone: repPhone, website, notes, status: 'active', 54 updated_at: new Date().toISOString() 55 }; 56 sb.from('vendors').upsert(dbEntry, { onConflict: 'name' }) 57 .then(({error}) => { 58 if (error) { 59 console.error('Vendor DB save error:', error); 60 toast('Warning: vendor saved locally but failed to save to database'); 61 } else { 62 console.log('Vendor saved to DB:', name); 63 } 64 }); 65 } catch(e) { console.error('Vendor save patch error:', e); } 66 }; 67 } 68 69 // === FIX 3: Override saveInstaller to persist to Supabase === 70 const _origSaveInstaller = typeof saveInstaller === 'function' ? saveInstaller : null; 71 if (_origSaveInstaller) { 72 window.saveInstaller = function(editIdx) { 73 const name = document.getElementById('inst-f-name')?.value?.trim(); 74 const company = document.getElementById('inst-f-company')?.value?.trim() || ''; 75 const phone = document.getElementById('inst-f-phone')?.value?.trim() || ''; 76 const email = document.getElementById('inst-f-email')?.value?.trim() || ''; 77 const specialty = document.getElementById('inst-f-specialty')?.value?.trim() || ''; 78 const notes = document.getElementById('inst-f-notes')?.value?.trim() || ''; 79 _origSaveInstaller(editIdx); 80 if (!name) return; 81 try { 82 const dbEntry = { 83 name, company, phone, email, 84 specialty: specialty ? specialty.split(',').map(s =>
84 s.trim()).filter(Boolean) : [], 85 notes, status: 'active', 86 updated_at: new Date().toISOString() 87 }; 88 sb.from('installers').upsert(dbEntry, { onConflict: 'name' }) 89 .then(({error}) => { 90 if (error) { 91 console.error('Installer DB save error:', error); 92 toast('Warning: installer saved locally but failed to save to database'); 93 } else { 94 console.log('Installer saved to DB:', name); 95 } 96 }); 97 } catch(e) { console.error('Installer save patch error:', e); } 98 }; 99 } 100 101 console.log('[patch.js] Runtime fixes applied (FIX 1-3)'); 102})(); 103 104// === FIX 4: Carton calculation + auto-round-up for ALL flooring products === 105(function initCartonCalc() { 106 var _updating = false; 107 108 // === FIX 26: strict SKU/name match first; ambiguous color falls through === 109 function findCoverage(text) { 110 if (typeof _productCatalogCache === 'undefined' || !_productCatalogCache) return 0; 111 112 // STRICT 1: exact SKU appears in the line text 113 for (var i = 0; i < _productCatalogCache.length; i++) { 114 var p = _productCatalogCache[i]; 115 if (!p.coverage) continue; 116 if (p.sku && p.sku.length >= 4 && text.indexOf(p.sku) !== -1) { 117 return parseFloat(p.coverage) || 0; 118 } 119 } 120 121 // STRICT 2: full product_name appears in the line text 122 for (var k = 0; k < _productCatalogCache.length; k++) { 123 var q = _productCatalogCache[k]; 124 if (!q.coverage || !q.product_name) continue; 125 if (q.product_name.length >= 10 && text.indexOf(q.product_name) !== -1) { 126 return parseFloat(q.coverage) || 0; 127 } 128 } 129 130 // FALLBACK: color-name heuristic (legacy behavior), 131 // but return 0 when candidates disagree instead of 132 // blindly returning the first one. 133 var matched = []; 134 for (var m = 0; m < _productCatalogCache.length; m++) { 135 var r = _productCatalogCache[m]; 136 if (!r.coverage || !r.product_name) continue; 137 var parts = r.product_name.split(' - '); 138 var colorName = parts.length > 1 ? parts[parts.length - 1].trim() : ''; 139 if (colorName && colorName.length > 2 && text.indexOf(colorName) !== -1) { 140 matched.push(r); 141 } 142 } 143 if (matched.length === 0) return 0; 144 var coverage = parseFloat(matched[0].coverage) || 0; 145 for (var j = 1; j < matched.length; j++) { 146 if (parseFloat(matched[j].coverage) !== coverage) return 0; // ambiguous â don't guess 147 } 148 return coverage; 149 } 150 151 function processEstimateItems() { 152 var estLines = document.getElementById('est-lines'); 153 if (!estLines) return; 154 var items = estLines.querySelectorAll(':scope > div'); 155 for (var i = 0; i < items.length; i++) { 156 var item = items[i]; 157 var inputs = item.querySelectorAll('input[type="number"]'); 158 if (inputs.length < 2) continue; 159 var text = item.textContent || ''; 160 var coverage = findCoverage(text); 161 if (coverage <= 0) { 162 var oldBadge = item.querySelector('.carton-badge'); 163 if (oldBadge) oldBadge.remove(); 164 continue; 165 } 166 var priceInput = inputs[0]; 167 var sqftInput = inputs[1]; 168 var sqft = parseFloat(sqftInput.value) || 0; 169 var cartons = sqft > 0 ? Math.ceil(sqft / coverage) : 0; 170 var actualSqft = cartons * coverage; 171 var badge = item.querySelector('.carton-badge'); 172 if (!badge) { 173 badge = document.createElement('div'); 174 badge.className = 'carton-badge'; 175 badge.style.cssText = 'font-size:12px;color:#c9a85c;margin:4px 0 0 0;font-weight:600;padding:2px 8px;'; 176 var descDiv = item.children[2]; 177 if (descDiv && descDiv.nextSibling) { 178 item.insertBefore(badge, descDiv.nextSibling); 179 } else { 180 item.appendChild(badge); 181 } 182 } 183 var badgeText = cartons > 0 184 ? '\u{1F4E6} ' + cartons + ' carton' + (cartons !== 1 ? 's' : '') + ' = ' + actualSqft.toFixed(2) + ' sf (' + coverage + ' sf/ctn)' 185 : '\u{1F4E6} ' + coverage + ' sf/carton'; 186 if (badge.textContent !== badgeText) badge.textContent = badgeText; 187 if (!sqftInput._cartonListener) { 188 sqftInput._cartonListener = true; 189 sqftInput.addEventListener('input', function() { 190 setTimeout(processEstimateItems, 100); 191 }); 192 sqftInput.addEventListener('blur', function() { 193 var sf = parseFloat(this.value) || 0; 194 if (sf <= 0 || coverage <= 0) return;
195 var crt = Math.ceil(sf / coverage); 196 var rounded = crt * coverage; 197 if (Math.abs(rounded - sf) > 0.01 && rounded > sf) { 198 this.value = rounded; 199 this.dispatchEvent(new Event('input', {bubbles: true})); 200 this.dispatchEvent(new Event('change', {bubbles: true})); 201 } 202 }); 203 } 204 } 205 } 206 207 function processQuoteItems() { 208 var quoteItems = document.querySelectorAll('.quote-item'); 209 if (quoteItems.length === 0) return; 210 quoteItems.forEach(function(itemDiv) { 211 var sku = (itemDiv.querySelector('.item-sku') || {}).value || ''; 212 var desc = (itemDiv.querySelector('.item-description') || {}).value || ''; 213 var sqftInput = itemDiv.querySelector('.item-sqft'); 214 var wastePctInput = itemDiv.querySelector('.item-waste-pct'); 215 if (!sqftInput) return; 216 var text = sku + ' ' + desc; 217 var coverage = findCoverage(text); 218 if (coverage <= 0) { 219 if (typeof _productCatalogCache !== 'undefined' && _productCatalogCache) { 220 var match = sku ? _productCatalogCache.find(function(p) { return p.sku === sku; }) : null; 221 if (!match && desc) match = _productCatalogCache.find(function(p) { return p.product_name === desc; }); 222 coverage = (match && match.coverage) ? parseFloat(match.coverage) || 0 : 0; 223 } 224 } 225 if (coverage <= 0) { 226 var old = itemDiv.querySelector('.carton-badge'); 227 if (old) old.remove(); 228 return; 229 } 230 var sqft = parseFloat(sqftInput.value) || 0; 231 var cartons = sqft > 0 ? Math.ceil(sqft / coverage) : 0; 232 var actualSqft = cartons * coverage; 233 var sqftParent = sqftInput.parentNode; 234 var badge = sqftParent.querySelector('.carton-badge'); 235 if (!badge) { 236 badge = document.createElement('div'); 237 badge.className = 'carton-badge'; 238 badge.style.cssText = 'font-size:11px;color:#c9a85c;margin-top:4px;font-weight:600;'; 239 sqftParent.appendChild(badge); 240 } 241 var badgeText = cartons > 0 242 ? '\u{1F4E6} ' + cartons + ' carton' + (cartons !== 1 ? 's' : '') + ' = ' + actualSqft.toFixed(2) + ' sf (' + coverage + ' sf/ctn)' 243 : '\u{1F4E6} ' + coverage + ' sf/carton'; 244 if (badge.textContent !== badgeText) badge.textContent = badgeText; 245 if (!sqftInput._cartonListener) { 246 sqftInput._cartonListener = true; 247 sqftInput.addEventListener('input', function() { setTimeout(processQuoteItems, 50); }); 248 sqftInput.addEventListener('change', function() { 249 setTimeout(function() { 250 processQuoteItems(); 251 if (typeof updateQuoteTotals === 'function') updateQuoteTotals(); 252 }, 50); 253 }); 254 (function(inp, cov) { 255 inp.addEventListener('blur', function() { 256 var sf = parseFloat(inp.value) || 0; 257 if (sf <= 0 || cov <= 0) return; 258 var crt = Math.ceil(sf / cov); 259 var rounded = crt * cov; 260 if (Math.abs(rounded - sf) > 0.01 && rounded > sf) { 261 inp.value = rounded.toFixed(2); 262 inp.dispatchEvent(new Event('input', {bubbles: true})); 263 inp.dispatchEvent(new Event('change', {bubbles: true})); 264 setTimeout(function() { 265 processQuoteItems(); 266 if (typeof updateQuoteTotals === 'function') updateQuoteTotals(); 267 }, 100); 268 } 269 }); 270 })(sqftInput, coverage); 271 } 272 }); 273 } 274 275 // === FIX 5: Labor line â show cost/sell rates in visible fields === 276 function fixLaborLines() { 277 var quoteItems = document.querySelectorAll('.quote-item'); 278 quoteItems.forEach(function(itemDiv) { 279 var typeEl = itemDiv.querySelector('.item-product-type'); 280 if (!typeEl || typeEl.value !== 'Labor') return; 281 var laborCostEl = itemDiv.querySelector('.item-labor-cost'); 282 var laborSellEl = itemDiv.querySelector('.item-labor-sell'); 283 var costSfEl = itemDiv.querySelector('.item-cost-sf'); 284 var sellSfEl = itemDiv.querySelector('.item-sell-sf'); 285 var totalEl = itemDiv.querySelector('.item-line-total'); 286 var sqftInput = itemDiv.querySelector('.item-sqft'); 287 var matCostEl = itemDiv.querySelector('.item-material-cost'); 288 var matSellEl = itemDiv.querySelector('.item-material-sell'); 289 if (!laborSellEl || !costSfEl || !sellSfEl) return; 290 var lc = parseFloat(laborCostEl ? laborCostEl.value : '0') || 0; 291 var ls = parseFloat(laborSellEl.value) || 0; 292 var sf = parseFloat(sqftInput ? sqftInput.value : '0') || 0; 293 var costRate = lc, sellRate = ls; 294 if (sf > 0 && ls > sf) { 295 var wpEl = itemDiv.querySelector('.item-waste-pct'); 296 var wp = wpEl ? (parseFloat(wpEl.value) || 10) : 10; 297 var tsf = sf * (1 + wp / 100); 298 if (tsf > 0) { costRate = lc / tsf; sellRate = ls / tsf; } 299 } 300 if (costRate > 0 && (!costSfEl.value || parseFloat(costSfEl.value) === 0)) { 301 costSfEl.value = costRate.toFixed(2); 302 } 303 if (sellRate > 0 && (!sellSfEl.value || parseFloat(sellSfEl.value) === 0)) { 304 sellSfEl.value = sellRate.toFixed(2); 305 } 306 if (matCostEl && parseFloat(matCostEl.value) > 0) matCostEl.value = '0'; 307 if (matSellEl && parseFloat(matSellEl.value) > 0) matSellEl.value = '0'; 308 if (ls > 0 && totalEl) { 309 var ct = parseFloat(totalEl.value) || 0; 310 if (ct > ls + 0.01) { 311 totalEl.value = ls.toFixed(2); 312 } 313 } 314 if (sqftInput && !sqftInput._laborFixListener) { 315 sqftInput._laborFixListener = true; 316 sqftInput.addEventListener('input', function() { setTimeout(fixLaborLines, 100); }); 317 sqftInput.addEventListener('change', function() { setTimeout(fixLaborLines, 100); }); 318 } 319 }); 320 } 321 322 function updateAll() { 323 if (_updating) return; 324 _updating = true; 325 try { 326 processEstimateItems(); 327 processQuoteItems(); 328 fixLaborLines(); 329 } finally { _updating = false; } 330 } 331 332 setInterval(updateAll, 500); 333 console.log('[patch.js] FIX 4+5: Carton calc + Labor display polling started'); 334})(); 335 336// === FIX 6: Invoice overlay close button â remove inline display:block that overrides CSS === 337(function fixInvoiceClose() { 338 var _origRender = typeof renderInvoiceDetail === 'function' ? renderInvoiceDetail : null; 339 if (!_origRender) return; 340 341 window.renderInvoiceDetail = async function(invoice) { 342 await _origRender(invoice); 343 // After original renders, remove the inline display style so CSS classes work properly 344 var ov = document.getElementById('ov-invoice-detail'); 345 if (ov) { 346 ov.style.removeProperty('display'); 347 // Ensure it's open via the class 348 if (!ov.classList.contains('open')) ov.classList.add('open'); 349 } 350 }; 351 console.log('[patch.js] FIX 6: Invoice close button fix applied'); 352})(); 353 354// === FIX 7: Fix loadJsPDF race condition â AutoTable must load AFTER jsPDF === 355(function fixLoadJsPDF() { 356 window.loadJsPDF = function() { 357 return new Promise(function(resolve, reject) { 358 // Check if already loaded 359 if (window.jspdf && window.jspdf.jsPDF) { 360 // Check autoTable on prototype (that's where the plugin registers) 361 try { 362 var testDoc = new window.jspdf.jsPDF(); 363 if (typeof testDoc.autoTable === 'function') { 364 resolve(); 365 return; 366 } 367 } catch(e) {} 368 } 369 370 function loadScript(src) { 371 return new Promise(function(res, rej) { 372 // Don't re-add if already in DOM 373 var existing = document.querySelector('script[src="' + src + '"]'); 374 if (existing) { res(); return; } 375 var s = document.createElement('script'); 376 s.src = src; 377 s.onload = res; 378 s.onerror = function() { rej(new Error('Failed to load: ' + src)); }; 379 document.head.appendChild(s); 380 }); 381 } 382 383 // Load jsPDF first, THEN AutoTable (it depends on jsPDF being present) 384 loadScript('https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js') 385 .then(function() { 386 return loadScript('https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js'); 387 }) 388 .then(resolve) 389 .catch(reject); 390 }); 391 }; 392 console.log('[patch.js] FIX 7: loadJsPDF sequential loading fix applied'); 393})(); 394 395// === FIX 8: View Contract â fetch HTML and open as blob to guarantee browser renders it === 396(function fixViewContract() { 397 document.addEventListener('click', function(e) { 398 var link = e.target.closest('a[href*="view=contract"]'); 399 if (!link) return; 400 e.preventDefault(); 401 e.stopPropagation(); 402 var url = link.href; 403 // Fetch the HTML and open it as a blob URL so the browser always renders it 404 fetch(url) 405 .then(function(resp) { return resp.text(); }) 406 .then(function(html) { 407 var blob = new Blob([html], { type: 'text/html; charset=utf-8' }); 408 var blobUrl = URL.createObjectURL(blob); 409 window.open(blobUrl, '_blank'); 410 // Clean up blob URL after a delay 411 setTimeout(function() { URL.revokeObjectURL(blobUrl); }, 60000); 412 }) 413 .catch(function(err) { 414 console.error('View Contract fetch error:', err); 415 // Fallback: just open the URL directly 416 window.open(url, '_blank'); 417 }); 418 }, true); 419 console.log('[patch.js] FIX 8: View Contract blob rendering fix applied'); 420})(); 421 422// === FIX 9: Quote save â fix empty date strings and null id causing DB errors === 423(function fixQuoteSave() { 424 var _origSaveQuote = typeof saveQuote === 'function' ? saveQuote : null; 425 if (!_origSaveQuote) return; 426 427 window.saveQuote = async function(quoteData, lineItems) { 428 // Fix 9a: Convert empty string dates to null (Postgres rejects "" for date columns) 429 if (quoteData.quote_date === '') quoteData.quote_date = null; 430 if (quoteData.valid_until === '') quoteData.valid_until = null; 431 432 // Fix 9b: Remove null id for new quotes (Postgres rejects null for serial NOT NULL columns) 433 if (quoteData.id === null || quoteData.id === undefined) { 434 delete quoteData.id; 435 } 436 437 // Fix 9c: Convert NaN numeric fields to 0 438 var numFields = ['material_subtotal', 'labor_subtotal', 'subtotal', 'discount', 439 'freight', 'surcharge', 'processing_fee_pct', 'processing_fee_amount', 440 'tax_rate', 'tax_amount', 'grand_total']; 441 for (var i = 0; i < numFields.length; i++) { 442 var f = numFields[i]; 443 if (quoteData[f] !== undefined && (isNaN(quoteData[f]) || quoteData[f] === null)) { 444 quoteData[f] = 0; 445 } 446 } 447 448 // Fix 9d: Re-fetch quote_number right before save to avoid UNIQUE conflicts 449 if (!quoteData.id) {
450 try { 451 var freshNum = await getNextQuoteNumber(); 452 quoteData.quote_number = freshNum; 453 } catch(e) { /* keep existing number */ } 454 } 455 456 return _origSaveQuote(quoteData, lineItems); 457 }; 458 console.log('[patch.js] FIX 9: Quote save date/null/NaN fix applied'); 459})(); 460 461// === FIX 10: New invoices use JH-XXXXXX format instead of INV-timestamp === 462(function fixInvoiceNumberFormat() { 463 var _origCreateInvoiceFromQuote = typeof createInvoiceFromQuote === 'function' ? createInvoiceFromQuote : null; 464 if (!_origCreateInvoiceFromQuote) return; 465 466 window.createInvoiceFromQuote = async function(quoteId) { 467 // Get the next invoice number in JH- format 468 var nextNum = 100000; 469 try { 470 var { data } = await sb.from('invoices') 471 .select('invoice_number') 472 .like('invoice_number', 'JH-%') 473 .order('invoice_number', { ascending: false }) 474 .limit(1); 475 if (data && data.length > 0) { 476 var lastNum = parseInt(data[0].invoice_number.replace('JH-', ''), 10); 477 if (!isNaN(lastNum)) nextNum = lastNum + 1; 478 } 479 } catch(e) { console.error('Invoice number lookup error:', e); } 480 481 window._nextInvoiceNum = 'JH-' + nextNum; 482 483 var result = await _origCreateInvoiceFromQuote(quoteId); 484 485 // After creation, fix the invoice_number if it used INV- format 486 if (window._nextInvoiceNum) { 487 try { 488 await sb.from('invoices') 489 .update({ invoice_number: window._nextInvoiceNum }) 490 .like('invoice_number', 'INV-%') 491 .eq('quote_id', quoteId); 492 } catch(e) { console.error('Invoice number fix error:', e); } 493 delete window._nextInvoiceNum; 494 } 495 496 return result; 497 }; 498 console.log('[patch.js] FIX 10: Invoice number format fix (JH-XXXXXX) applied'); 499})(); 500 501// === FIX 11: Project Tracker layout & color fix to match Operations pages === 502(function fixProjectTrackerLayout() { 503 504 // Override renderProjectsTab to match Quotes/Invoices/WO layout 505 var _origRenderProjectsTab = typeof renderProjectsTab === 'function' ? renderProjectsTab : null; 506 if (!_origRenderProjectsTab) { console.log('[patch.js] FIX 11: renderProjectsTab not found, skipping'); return; } 507 508 window.renderProjectsTab = async function() { 509 var tab = document.getElementById('tab-projects'); 510 if (!tab) return; 511 512 tab.innerHTML = '\ 513 <div style="padding:20px;background:var(--d1);min-height:100vh;">\ 514 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;flex-wrap:wrap;gap:10px;">\ 515 <h2 style="color:var(--t);margin:0;">Project Tracker</h2>\ 516 <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">\ 517 <div style="position:relative;">\ 518 <input id="proj-search" type="text" placeholder="Search projects..." onkeyup="filterProjects()" style="padding:8px 12px 8px 34px;background:var(--d2);color:var(--t);border:1px solid var(--b);border-radius:6px;width:220px;font-size:14px;">\ 519 <span style="position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--t3);">🔍</span>\ 520 </div>\ 521 <select id="proj-status-filter" onchange="filterProjects()" style="padding:8px 16px;background:var(--d2);color:var(--t);border:1px solid var(--b);border-radius:6px;font-size:14px;cursor:pointer;">\ 522 <option value="">All Statuses</option>\ 523 </select>\ 524 <button onclick="openInstallerTechsPanel()" style="padding:10px 20px;background:var(--d3);color:var(--gold);border:1px solid var(--gold);border-radius:6px;cursor:pointer;font-weight:bold;">\ 525 👷 Manage Techs\ 526 </button>\ 527 <button onclick="openNewProjectForm()" style="padding:10px 20px;background:var(--gold);color:#000;border:none;border-radius:6px;cursor:pointer;font-weight:bold;">\ 528 + New Project\ 529 </button>\ 530 </div>\ 531 </div>\ 532 <div id="proj-filters" style="display:flex;gap:10px;margin-bottom:20px;flex-wrap:wrap;"></div>\ 533 <div id="proj-summary-cards" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:20px;"></div>\ 534 <div id="proj-list" style="display:flex;flex-direction:column;gap:10px;"></div>\ 535 </div>'; 536 537 // Populate status filter options 538 var sel = document.getElementById('proj-status-filter'); 539 if (sel && typeof PROJECT_STATUSES !== 'undefined') { 540 PROJECT_STATUSES.forEach(function(s) { 541 var opt = document.createElement('option'); 542 opt.value = s.value; 543 opt.textContent = s.icon + ' ' + s.label; 544 sel.appendChild(opt); 545 }); 546 } 547 548 // Build filter pills 549 var pillContainer = document.getElementById('proj-filters'); 550 if (pillContainer && typeof PROJECT_STATUSES !== 'undefined') { 551 var pillColors = { 552 planning: '#4a4a4a', material_ordered: '#003d7a', material_in_transit: '#003d7a', 553 material_received: '#1a5a2f', scheduled: '#b8860b', in_progress: '#003d7a', 554 complete: '#1a5a2f', punch_list: '#b8860b', project_closed: '#4a4a4a' 555 }; 556 var pillsHtml = '<button class="filter-pill active" data-status="all" style="padding:8px 16px;background:var(--d3);color:var(--t);border:1px solid var(--b);border-radius:20px;cursor:pointer;">All</button>'; 557 PROJECT_STATUSES.forEach(function(s) { 558 var bg = pillColors[s.value] || '#4a4a4a'; 559 pillsHtml += '<button class="filter-pill" data-status="' + s.value + '" style="padding:8px 16px;background:' + bg + ';color:var(--t);border:1px solid var(--b);border-radius:20px;cursor:pointer;">' + s.icon + ' ' + s.label + '</button>'; 560 }); 561 pillContainer.innerHTML = pillsHtml; 562 563 // Pill click handlers 564 pillContainer.querySelectorAll('.filter-pill').forEach(function(pill) { 565 pill.addEventListener('click', function(e) { 566 pillContainer.querySelectorAll('.filter-pill').forEach(function(p) { p.classList.remove('active'); }); 567 e.target.classList.add('active'); 568 var status = e.target.dataset.status; 569 var sel2 = document.getElementById('proj-status-filter'); 570 if (sel2) sel2.value = (status === 'all') ? '' : status; 571 filterProjects(); 572 }); 573 }); 574 } 575 576 await loadProjects(); 577 renderProjectSummary(); 578 renderProjectList(); 579 }; 580 581 // Override renderProjectSummary with corrected theme vars 582 window.renderProjectSummary = function() { 583 var el = document.getElementById('proj-summary-cards'); 584 if (!el || typeof PROJECT_STATUSES === 'undefined') return; 585 586 var counts = {}; 587 PROJECT_STATUSES.forEach(function(s) { counts[s.value] = 0; }); 588 (typeof projectsData !== 'undefined' ? projectsData : []).forEac
588h(function(p) { 589 if (counts[p.status] !== undefined) counts[p.status]++; 590 }); 591 592 el.innerHTML = PROJECT_STATUSES.filter(function(s) { 593 return counts[s.value] > 0 || ['planning','in_progress','complete'].indexOf(s.value) >= 0; 594 }).map(function(s) { 595 return '<div onclick="document.getElementById(\'proj-status-filter\').value=\'' + s.value + '\';filterProjects()" style="background:var(--d3);border:1px solid var(--b);border-radius:8px;padding:14px;cursor:pointer;text-align:center;transition:all 0.2s;border-left:3px solid ' + s.color + ';">' 596 + '<div style="font-size:22px;">' + s.icon + '</div>' 597 + '<div style="font-size:22px;font-weight:700;color:' + s.color + ';">' + counts[s.value] + '</div>' 598 + '<div style="font-size:11px;color:var(--t3);text-transform:uppercase;letter-spacing:0.5px;">' + s.label + '</div>' 599 + '</div>'; 600 }).join(''); 601 }; 602 603 // Override renderProjectList with corrected theme vars 604 window.renderProjectList = function() { 605 var el = document.getElementById('proj-list'); 606 if (!el) return; 607 608 var statusFilter = (document.getElementById('proj-status-filter') || {}).value || ''; 609 var search = ((document.getElementById('proj-search') || {}).value || '').toLowerCase(); 610 var data = typeof projectsData !== 'undefined' ? projectsData : []; 611 612 var filtered = data; 613 if (statusFilter) filtered = filtered.filter(function(p) { return p.status === statusFilter; }); 614 if (search) filtered = filtered.filter(function(p) { 615 return (p.name || '').toLowerCase().indexOf(search) >= 0 || 616 (p.customer || '').toLowerCase().indexOf(search) >= 0 || 617 (p.job_address || '').toLowerCase().indexOf(search) >= 0 || 618 (p.project_number || '').toLowerCase().indexOf(search) >= 0 || 619 (p.installer_name || '').toLowerCase().indexOf(search) >= 0; 620 }); 621 622 if (filtered.length === 0) { 623 el.innerHTML = '<div style="text-align:center;padding:40px;color:var(--t3);"><div style="font-size:48px;margin-bottom:10px;">📋</div><p>No projects found</p></div>'; 624 return; 625 } 626 627 el.innerHTML = filtered.map(function(p) { 628 var si = getStatusInfo(p.status); 629 var daysInstall = p.install_start_date ? Math.ceil((new Date(p.install_start_date) - new Date()) / 86400000) : null; 630 var daysETA = p.material_eta ? Math.ceil((new Date(p.material_eta) - new Date()) / 86400000) : null; 631 var urgentBadge = ''; 632 if (daysInstall !== null && daysInstall >= 0 && daysInstall <= 3 && p.status !== 'complete') { 633 urgentBadge = '<span style="background:#ef4444;color:#fff;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;">Install in ' + daysInstall + 'd</span>'; 634 } else if (daysETA !== null && daysETA >= 0 && daysETA <= 2 && !p.material_received_date) { 635 urgentBadge = '<span style="background:#3b82f6;color:#fff;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;">Delivery in ' + daysETA + 'd</span>'; 636 } 637 638 return '<div onclick="openProjectDetail(' + p.id + ')" style="background:var(--d3);border:1px solid var(--b);border-radius:8px;padding:16px;cursor:pointer;transition:all 0.2s;border-left:3px solid ' + si.color + ';">' 639 + '<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:8px;">' 640 + '<div style="flex:1;
640min-width:200px;">' 641 + '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">' 642 + '<span style="font-weight:700;font-size:15px;color:var(--t);">' + esc(p.name || 'Untitled Project') + '</span>' 643 + urgentBadge + '</div>' 644 + '<div style="font-size:12px;color:var(--t2);">' 645 + esc(p.project_number || '') + (p.customer ? ' • ' + esc(p.customer) : '') 646 + (p.job_address ? ' • ' + esc(p.job_address) : '') + '</div></div>' 647 + '<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">' 648 + (p.installer_name ? '<span style="font-size:12px;color:var(--t2);">👷 ' + esc(p.installer_name) + '</span>' : '') 649 + (p.install_start_date ? '<span style="font-size:12px;color:var(--t2);">📅 ' + new Date(p.install_start_date).toLocaleDateString() + '</span>' : '') 650 + '<span style="display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:12px;font-size:11px;font-weight:600;background:' + si.color + '22;color:' + si.color + ';">' + si.icon + ' ' + si.label + '</span>' 651 + '</div></div>' 652 + renderProjectProgressBar(p) + '</div>'; 653 }).join(''); 654 }; 655 656 // Override renderProjectProgressBar with gold gradient 657 window.renderProjectProgressBar = function(p) { 658 var stages = ['planning','material_ordered','material_in_transit','material_received','scheduled','in_progress','complete']; 659 var currentIdx = stages.indexOf(p.status); 660 if (currentIdx < 0) return ''; 661 var pct = Math.round((currentIdx / (stages.length - 1)) * 100); 662 return '<div style="margin-top:10px;">' 663 + '<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--t3);margin-bottom:3px;"><span>Planning</span><span>Complete</span></div>' 664 + '<div style="height:4px;background:var(--b);border-radius:2px;overflow:hidden;">' 665 + '<div style="height:100%;width:' + pct + '%;background:linear-gradient(90deg,var(--gold),#22c55e);border-radius:2px;transition:width 0.3s;"></div>' 666 + '</div></div>'; 667 }; 668 669 // Fix project detail overlay to use correct theme vars 670 var _origOpenProjectDetail = typeof openProjectDetail === 'function' ? openProjectDetail : null; 671 if (_origOpenProjectDetail) { 672 window.openProjectDetail = async function(projectId) { 673 await _origOpenProjectDetail(projectId); 674 // Post-render fix: replace incorrect CSS vars in the overlay 675 var ov = document.getElementById('ov-project-detail'); 676 if (ov) { 677 var html = ov.innerHTML; 678 html = html.replace(/var\(--card\)/g, 'var(--d3)'); 679 html = html.replace(/var\(--text\)/g, 'var(--t)'); 680 html = html.replace(/var\(--border\)/g, 'var(--b)'); 681 html = html.replace(/var\(--accent\)/g, 'var(--gold)'); 682 html = html.replace(/var\(--bg\)/g, 'var(--d2)'); 683 html = html.replace(/var\(--muted\)/g, 'var(--t2)'); 684 ov.innerHTML = html; 685 // Re-open overlay since innerHTML reset may have closed it 686 setTimeout(function() { openOv('ov-project-detail'); }, 10); 687 } 688 }; 689 } 690 691 console.log('[patch.js] FIX 11: Project Tracker layout & color fix applied'); 692})(); 693 694// === FIX 12: Tutorial / Help section injected into sidebar === 695(function addTutorialSection() { 696 697 // Wait for DOM to be ready 698 function init() { 699 var sidebar = document.querySelector('.sidebar-nav, .sb-nav, nav'); 700 if (!sidebar) { setTimeout(init, 500); return; } 701 702 // Repurpose existing "Help & Tutorials" nav link instead of creating a duplicate 703 var helpLink = document.querySelector('[data-tab="help"]'); 704 if (helpLink) { 705 helpLink.setAttribute('data-tab', 'tutorial'); 706 helpLink.setAttribute('onclick', "sideNav('tutorial',this)"); 707 } 708 709 // Create the tab-tutorial container (reuse tab-help if it exists, or create new) 710 var helpTab = document.getElementById('tab-help'); 711 if (helpTab) { 712 helpTab.id = 'tab-tutorial'; 713 } else { 714 var mainContent = document.querySelector('.mc, .main-content, [id^="tab-"]')?.parentElement; 715 if (mainContent) { 716 var tabDiv = document.createElement('div'); 717 tabDiv.id = 'tab-tutorial'; 718 tabDiv.className = 'tab'; 719 tabDiv.style.display = 'none'; 720 mainContent.appendChild(tabDiv); 721 } 722 } 723 724 // Register the render function â unified Help & Tutorials page 725 window.renderTutorialTab = function() { 726 var tab = document.getElementById('tab-tutorial'); 727 if (!tab) return; 728 729 // Build category quick-filter buttons from HELP_TUTORIALS 730 var catButtons = ''; 731 var cats = typeof HELP_TUTORIALS !== 'undefined' ? HELP_TUTORIALS : []; 732 catButtons += '<button onclick="helpFilterCat(\'\')" class="hf-cat-btn hf-cat-active" data-cat="" style="padding:8px 14px;border-radius:20px;border:1px solid var(--gold);background:var(--gold);color:#111;font-size:12px;font-weight:700;cursor:pointer;transition:all .2s;white-space:nowrap">All Topics</button>';
733 cats.forEach(function(cat) { 734 catButtons += '<button onclick="helpFilterCat(\'' + cat.category.replace(/'/g,"\\'") + '\')" class="hf-cat-btn" data-cat="' + cat.category + '" style="padding:8px 14px;border-radius:20px;border:1px solid var(--b);background:var(--d3);color:var(--t2);font-size:12px;font-weight:600;cursor:pointer;transition:all .2s;white-space:nowrap">' + cat.icon + ' ' + cat.category + '</button>'; 735 }); 736 737 tab.innerHTML = '<div style="max-width:940px;margin:0 auto;padding:20px 16px">' + 738 // Header 739 '<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:6px">' + 740 '<div><h1 style="margin:0;font-size:22px;font-weight:800;color:var(--t)">ð Help & Tutorials</h1>' + 741 '<p style="margin:4px 0 0;font-size:13px;color:var(--t3)">Everything your reps need to know â search or browse by category</p></div>' + 742 '<div style="display:flex;gap:8px">' + 743 '<button onclick="helpToggleView(\'guide\')" id="hf-view-guide" style="padding:6px 14px;border-radius:8px;border:1px solid var(--gold);background:var(--gold);color:#111;font-size:12px;font-weight:700;cursor:pointer">ð Quick Start Guide</button>' + 744 '<button onclick="helpToggleView(\'qa\')" id="hf-view-qa" style="padding:6px 14px;border-radius:8px;border:1px solid var(--b);background:var(--d3);color:var(--t2);font-size:12px;font-weight:600;cursor:pointer">â Q&A Search</button>' + 745 '</div>' + 746 '</div>' + 747 // Search bar â always visible 748 '<div style="position:sticky;top:0;z-index:10;background:var(--d);padding:12px 0 8px">' + 749 '<input id="help-search" type="text" placeholder="ð Type to search... (e.g. quote, margin, tax, kanban, carton, template)" ' + 750 'oninput="helpSmartSearch(this.value)" ' + 751 'style="width:100%;padding:14px 18px;background:var(--d2);color:var(--t);border:1px solid var(--gold);border-radius:12px;font-size:15px;box-sizing:border-box;outline:none">' + 752 '<div id="help-search-count" style="font-size:11px;color:var(--t3);margin-top:4px;padding-left:4px"></div>' + 753 '</div>' + 754 // Category filter pills 755 '<div id="hf-cat-pills" style="display:flex;flex-wrap:wrap;gap:8px;margin:10px 0 16px;padding-bottom:8px;border-bottom:1px solid var(--b)">' + catButtons + '</div>' + 756 // Quick Start Guide section 757 '<div id="hf-guide-section">' + buildTutorialHTML() + '</div>' + 758 // Q&A section 759 '<div id="hf-qa-section" style="display:none"><div id="help-content"></div></div>' + 760 '</div>'; 761 762 // Render Q&A content 763 if (typeof filterHelpTopics === 'function') filterHelpTopics(''); 764 765 // Smart search â searches BOTH guide and Q&A 766 window.helpSmartSearch = function(q) { 767 q = (q || '').trim(); 768 if (!q) { 769 // Reset to current view 770 var guideBtn = document.getElementById('hf-view-guide'); 771 if (guideBtn && guideBtn.style.background.indexOf('gold') > -1) { 772 document.getElementById('hf-guide-section').style.display = ''; 773 document.getElementById('hf-qa-section').style.display = 'none'; 774 } 775 if (typeof filterHelpTopics === 'function') filterHelpTopics(''); 776 document.getElementById('help-search-count').textContent = ''; 777 // Reset category pills 778 document.querySelectorAll('.hf-cat-btn').forEach(function(b){ b.style.background='var(--d3)';b.style.color='var(--t2)';b.style.borderColor='var(--b)'; }); 779 var allBtn = document.querySelector('.hf-cat-btn[data-cat=""]'); 780 if(allBtn){allBtn.style.background='var(--gold)';allBtn.style.color='#111';allBtn.style.borderColor='var(--gold)';} 781 return; 782 } 783 // When searching, show Q&A and hide guide 784 document.getElementById('hf-guide-section').style.display = 'none'; 785 document.getElementById('hf-qa-section').style.display = ''; 786 // Update view toggle buttons 787 document.getElementById('hf-view-guide').style.background = 'var(--d3)'; 788 document.getElementById('hf-view-guide').style.color = 'var(--t2)'; 789 document.getElementById('hf-view-guide').style.borderColor = 'var(--b)'; 790 document.getElementById('hf-view-qa').style.background = 'var(--gold)'; 791 document.getElementById('hf-view-qa').style.color = '#111'; 792 document.getElementById('hf-view-qa').style.borderColor = 'var(--gold)'; 793 if (typeof filterHelpTopics === 'function') filterHelpTopics(q); 794 // Count results 795 var results = document.querySelectorAll('#help-content [id^="help-body-"]'); 796 var countEl = document.getElementById('help-search-count'); 797 if (countEl) countEl.textContent = results.length + ' result' + (results.length !== 1 ? 's' : '') + ' for "' + q + '"'; 798 }; 799 800 // Toggle between Guide and Q&A views 801 window.helpToggleView = function(view) { 802 var guide = document.getElementById('hf-guide-section'); 803 var qa = document.getElementById('hf-qa-section'); 804 var gBtn = document.getElementById('hf-view-guide'); 805 var qBtn = document.getElementById('hf-view-qa'); 806 if (view === 'guide') { 807 guide.style.display = ''; 808 qa.style.display = 'none'; 809 gBtn.style.background = 'var(--gold)'; gBtn.style.color = '#111'; gBtn.style.borderColor = 'var(--gold)'; 810 qBtn.style.background = 'var(--d3)'; qBtn.style.color = 'var(--t2)'; qBtn.style.borderColor = 'var(--b)'; 811 } else { 812 guide.style.display = 'none'; 813 qa.style.display = ''; 814 qBtn.style.background = 'var(--gold)'; qBtn.style.color = '#111'; qBtn.style.borderColor = 'var(--gold)'; 815 gBtn.style.background = 'var(--d3)'; gBtn.style.color = 'var(--t2)'; gBtn.style.borderColor = 'var(--b)'; 816 if (typeof filterHelpTopics === 'function') filterHelpTopics(''); 817 } 818 // Clear search 819 var si = document.getElementById('help-search'); 820 if (si) si.value = ''; 821 document.getElementById('help-search-count').textContent = ''; 822 }; 823 824 // Category filter 825 window.helpFilterCat = function(cat) { 826 // Switch to Q&A view 827 helpToggleView('qa'); 828 // Highlight active pill 829 document.querySelectorAll('.hf-cat-btn').forEach(function(b) { 830 var isCurrent = b.getAttribute('data-cat') === cat; 831 b.style.background = isCurrent ? 'var(--gold)' : 'var(--d3)'; 832 b.style.color = isCurrent ? '#111' : 'var(--t2)'; 833 b.style.borderColor = isCurrent ? 'var(--gold)' : 'var(--b)'; 834 }); 835 if (typeof filterHelpTopics === 'function') filterHelpTopics(cat); 836 }; 837 }; 838 839 // Hook into sideNav to handle tutorial tab 840 var _origSideNav = typeof sideNav === 'function' ? sideNav : null; 841 if (_origSideNav) { 842 window.sideNav = function(name, el) { 843 // Always hide tutorial tab when navigating to ANY other tab 844 if (name !== 'tutorial') { 845 // Hide tutorial tab and clear any inline display styles we set 846 var tutTab = document.getElementById('tab-tutorial'); 847 if (tutTab) tutTab.style.display = 'none'; 848 // Clear inline display on all other tabs so CSS classes work properly 849 document.querySelectorAll('[id^="tab-"]:not(#tab-tutorial)').forEach(function(t) { 850 t.style.removeProperty('display'); 851 }); 852 return _origSideNav(name, el); 853 } 854 // Tutorial tab: let original sideNav update sidebar active state 855 _origSideNav(name, el); 856 // Hide all other tabs by removing their active class (don't use inl
856ine display) 857 document.querySelectorAll('[id^="tab-"]:not(#tab-tutorial)').forEach(function(t) { 858 t.classList.remove('active'); 859 t.style.removeProperty('display'); 860 }); 861 // Show tutorial tab with inline display since it's not managed by goTab 862 var tutTab = document.getElementById('tab-tutorial'); 863 if (tutTab) tutTab.style.display = 'block'; 864 renderTutorialTab(); 865 // Update page header 866 var header = document.querySelector('.mc-header h1, .page-title'); 867 if (header) header.textContent = 'Tutorial'; 868 window.location.hash = '#tutorial'; 869 }; 870 } 871 } 872 873 function buildTutorialHTML() { 874 var gold = 'var(--gold)'; 875 var sections = [ 876 { 877 id: 'tut-overview', 878 icon: '🏠', 879 title: 'How Jamail Hardwoods Portal Works', 880 content: '<p style="font-size:14px;line-height:1.8;color:var(--t2);margin-bottom:16px;">The portal follows a standard B2B flooring sales workflow. Here is the typical lifecycle of a project from first contact to completion:</p>' 881 + '<div style="display:flex;flex-wrap:wrap;gap:12px;margin-bottom:20px;">' 882 + stepBadge('1', 'Lead Created', '#4a4a4a') 883 + stepBadge('2', 'Quote Sent', '#003d7a') 884 + stepBadge('3', 'Quote Accepted', '#1a5a2f') 885 + stepBadge('4', 'Invoice Sent & Signed', '#b8860b') 886 + stepBadge('5', 'Work Order Created', '#003d7a') 887 + stepBadge('6', 'Materials Ordered (PO)', '#003d7a') 888 + stepBadge('7', 'Install Scheduled', '#b8860b') 889 + stepBadge('8', 'Project Completed', '#1a5a2f') 890 + '</div>' 891 + '<p style="font-size:13px;color:var(--t3);">Not every project follows all steps. A floor inspection, for example, may only need a Quote â Invoice â Paid â Done.</p>' 892 }, 893 { 894 id: 'tut-quotes', 895 icon: '📄', 896 title: 'Quotes', 897 content: '<h4 style="color:var(--gold);margin-bottom:8px;">Creating a Quote</h4>' 898 + '<ol style="color:var(--t2);line-height:2.2;padding-left:20px;">' 899 + '<li>Go to <strong>Quotes</strong> in the sidebar</li>' 900 + '<li>Click <strong>+ New Quote</strong></li>' 901 + '<li>Select the lead/company or create a new one</li>' 902 + '<li>Add line items â materials, labor, freight, surcharges</li>' 903 + '<li>Set the quote date and valid-until date</li>' 904 + '<li>Click <strong>Save</strong> (status: Draft)</li>' 905 + '</ol>' 906 + '<h4 style="color:var(--gold);margin:16px 0 8px;">Sending a Quote</h4>' 907 + '<ol style="color:var(--t2);line-height:2.2;padding-left:20px;">' 908 + '<li>Open the quote and click <strong>Send Quote</strong></li>' 909 + '<li>The client receives an email with a link to view the quote</li>' 910 + '<li>Status changes: Draft â <strong>Sent</strong> â <strong>Quote Viewed</strong> (when client opens it)</li>' 911 + '<li>Client can accept or request changes</li>' 912 + '<li>Once accepted, status becomes <strong>Accepted</strong></li>' 913 + '</ol>' 914 + '<h4 style="color:var(--gold);margin:16px 0 8px;">Quote Statuses</h4>' 915 + statusTable([ 916 ['Draft', 'Quote created but not sent yet'], 917 ['Sent', 'Emailed to client'], 918 ['Quote Viewed', 'Client opened the quote link'], 919 ['Quote Re-Opened', 'Client came back to view again'], 920 ['Accepted', 'Client accepted the quote'], 921 ['Declined', 'Client declined'] 922 ]) 923 }, 924 { 925 id: 'tut-invoices', 926 icon: '💳', 927 title: 'Invoices', 928 content: '<h4 style="color:var(--gold);margin-bottom:8px;">Creating an Invoice</h4>' 929 + '<ol style="color:var(--t2);line-height:2.2;padding-left:20px;">' 930 + '<li>From an accepted quote, click <strong>Create Invoice</strong></li>' 931 + '<li>Or go to <strong>Invoices â + Create Invoice</strong> for standalone invoices</li>' 932 + '<li>The invoice number is auto-generated as <strong>JH-XXXXXX</strong></li>' 933 + '<li>Review line items, totals, tax, freight, and processing fees</li>' 934 + '<li>Click <strong>Send to Customer</strong></li>' 935 + '</ol>' 936 + '<h4 style="color:var(--gold);margin:16px 0 8px;">Client Signing & Payment</h4>' 937 + '<ol style="color:var(--t2);line-height:2.2;padding-left:20px;">' 938 + '<li>Client receives email with invoice link</li>' 939 + '<li>Client reviews the contract and signs electronically</li>' 940 + '<li>Client pays via Stripe (credit card) or offline</li>' 941 + '<li>Status updates: Sent â <strong>Invoice Signed</strong> â <strong>Paid</strong> / <strong>Partially Paid</strong></li>' 942 + '</ol>' 943 + '<h4 style="color:var(--gold);margin:16px 0 8px;">Invoice Statuses</h4>' 944 + statusTable([ 945 ['Draft', 'Created but not sent'], 946 ['Sent To Customer', 'Emailed to client'], 947 ['Invoice Confirmed', 'Client acknowledged receipt'], 948 ['Invoice Signed', 'Client signed the contract'], 949 ['Partially Paid', 'Some payment received'],
950 ['Fully Paid / Paid', 'Payment complete'], 951 ['Completed', 'Imported from FloorzAP as completed'], 952 ['Void', 'Invoice cancelled/voided'], 953 ['Cancelled', 'Invoice cancelled'] 954 ]) 955 }, 956 { 957 id: 'tut-workorders', 958 icon: '🔧', 959 title: 'Work Orders', 960 content: '<h4 style="color:var(--gold);margin-bottom:8px;">Creating a Work Order</h4>' 961 + '<ol style="color:var(--t2);line-height:2.2;padding-left:20px;">' 962 + '<li>Go to <strong>Work Orders</strong> in the sidebar</li>' 963 + '<li>Work orders are typically created from an invoice or quote</li>' 964 + '<li>They track the actual job: materials cost, labor cost, contractor, schedule date</li>' 965 + '<li>Each WO has a number like <strong>WO-100XXX</strong></li>' 966 + '</ol>' 967 + '<h4 style="color:var(--gold);margin:16px 0 8px;">Managing a Work Order</h4>' 968 + '<ol style="color:var(--t2);line-height:2.2;padding-left:20px;">' 969 + '<li>Assign a <strong>contractor</strong> (installer)</li>' 970 + '<li>Set the <strong>schedule date</strong></li>' 971 + '<li>Track <strong>material cost vs. labor cost</strong> for profit calculations</li>' 972 + '<li>Record <strong>payments</strong> as they come in</li>' 973 + '<li>The WO shows real-time profit %, net profit, and commission</li>' 974 + '</ol>' 975 + '<h4 style="color:var(--gold);margin:16px 0 8px;">Work Order Statuses</h4>' 976 + statusTable([ 977 ['Pending', 'WO created, not yet started'], 978 ['Materials Ordered', 'Waiting for materials to arrive'], 979 ['Scheduled', 'Install date set with contractor'], 980 ['In Progress', 'Installation underway (shows % complete)'], 981 ['Completed', 'Job finished'] 982 ]) 983 }, 984 { 985 id: 'tut-pos', 986 icon: '📦', 987 title: 'Purchase Orders', 988 content: '<h4 style="color:var(--gold);margin-bottom:8px;">What are Purchase Orders?</h4>' 989 + '<p style="color:var(--t2);line-height:1.8;margin-bottom:12px;">Purchase Orders (POs) are used to order materials from your suppliers/vendors. They track what was ordered, from whom, when it ships, and when it arrives.</p>' 990 + '<h4 style="color:var(--gold);margin:16px 0 8px;">Creating a PO</h4>' 991 + '<ol style="color:var(--t2);line-height:2.2;padding-left:20px;">' 992 + '<li>Go to <strong>Purchase Orders â + New PO</strong></li>' 993 + '<li>Select the vendor/supplier</li>' 994 + '<li>Add the materials being ordered with quantities and costs</li>' 995 + '<li>Link it to the relevant work order or project</li>' 996 + '<li>Save and send to vendor</li>' 997 + '</ol>' 998 + '<h4 style="color:var(--gold);margin:16px 0 8px;">PO Statuses</h4>' 999 + statusTable([ 1000 ['Draft', 'PO created, not yet sent to vendor'], 1001 ['Sent', 'Sent to vendor for fulfillment'], 1002 ['Confirmed', 'Vendor confirmed the order'], 1003 ['Shipped', 'Materials are in transit'], 1004 ['Received', 'Materials arrived at job site or warehouse'] 1005 ]) 1006 }, 1007 { 1008 id: 'tut-schedule', 1009 icon: '📅', 1010 title: 'Schedule (Install & Delivery)', 1011 content: '<h4 style="color:var(--gold);margin-bottom:8px;">Install & Delivery Schedule</h4>' 1012 + '<p style="color:var(--t2);line-height:1.8;margin-bottom:12px;">The Schedule section manages contractor installs and material deliveries with a calendar view.</p>' 1013 + '<h4 style="color:var(--gold);margin:16px 0 8px;">Scheduling an Install</h4>' 1014 + '<ol style="color:var(--t2);line-height:2.2;padding-left:20px;">' 1015 + '<li>Go to <strong>Schedule â + New Install</strong></li>' 1016 + '<li>Select the job/work order</li>' 1017 + '<li>Assign a <strong>contractor</strong></li>' 1018 + '<li>Set <strong>start date, end date, start time, end time</strong></li>' 1019 + '<li>Add <strong>special instructions</strong> and contractor notes</li>' 1020 + '<li>Choose who to notify: Client, PM, Sales Rep</li>' 1021 + '</ol>' 1022 + '<h4 style="color:var(--gold);margin:16px 0 8px;">Schedule Statuses</h4>' 1023 + statusTable([ 1024 ['New', 'Install entry created'], 1025 ['Assigned', 'Contractor assigned but not scheduled'],
1026 ['Ready To Schedule', 'Materials received, ready to book date'], 1027 ['Scheduled', 'Date confirmed with contractor'], 1028 ['In Progress', 'Installation underway'], 1029 ['Labor Complete', 'Install finished, pending payment'], 1030 ['Ready for Payment', 'Awaiting contractor payment'], 1031 ['Paid', 'Contractor has been paid'] 1032 ]) 1033 }, 1034 { 1035 id: 'tut-projects', 1036 icon: '🏗', 1037 title: 'Project Tracker', 1038 content: '<h4 style="color:var(--gold);margin-bottom:8px;">What is the Project Tracker?</h4>' 1039 + '<p style="color:var(--t2);line-height:1.8;margin-bottom:12px;">The Project Tracker gives you a bird\'s-eye view of a job from planning through completion. It ties together deliveries, installations, milestones, and updates in one place.</p>' 1040 + '<h4 style="color:var(--gold);margin:16px 0 8px;">Project Lifecycle</h4>' 1041 + '<ol style="color:var(--t2);line-height:2.2;padding-left:20px;">' 1042 + '<li><strong>Planning</strong> â Project created, gathering details</li>' 1043 + '<li><strong>Material Ordered</strong> â PO sent to vendor</li>' 1044 + '<li><strong>Material In Transit</strong> â Shipment on the way</li>' 1045 + '<li><strong>Material Received</strong> â Delivered to site/warehouse</li>' 1046 + '<li><strong>Scheduled</strong> â Install date booked with contractor</li>' 1047 + '<li><strong>In Progress</strong> â Installation underway</li>' 1048 + '<li><strong>Complete</strong> â Job finished, click the <strong>Complete</strong> button</li>' 1049 + '</ol>' 1050 + '<h4 style="color:var(--gold);margin:16px 0 8px;">Marking a Project Complete</h4>' 1051 + '<ol style="color:var(--t2);line-height:2.2;padding-left:20px;">' 1052 + '<li>Open the project from the Project Tracker list</li>' 1053 + '<li>Change the status dropdown to <strong>Complete</strong></li>' 1054 + '<li>Or click the <strong>Complete</strong> button in the top-right corner</li>' 1055 + '<li>Go to the lead record and update the stage to <strong>Won</strong> or <strong>Active Client</strong></li>' 1056 + '</ol>' 1057 }, 1058 { 1059 id: 'tut-completion', 1060 icon: '✅', 1061 title: 'Closing Out a Job (Full Checklist)', 1062 content: '<p style="color:var(--t2);line-height:1.8;margin-bottom:16px;">When a project is done, make sure these items are checked off:</p>' 1063 + '<div style="background:var(--d2);border:1px solid var(--b);border-radius:8px;padding:20px;">' 1064 + '<div style="display:flex;flex-direction:column;gap:12px;">' 1065 + checkItem('Invoice status is <strong>Paid</strong> or <strong>Fully Paid</strong>') 1066 + checkItem('Contract is signed (check Signed badge on invoice)') 1067 + checkItem('Work Order status is <strong>Completed</strong>') 1068 + checkItem('Contractor has been paid (Schedule â Paid status)') 1069 + checkItem('Materials all received (no pending deliveries)') 1070 + checkItem('Project Tracker set to <strong>Complete</strong>') 1071 + checkItem('Lead stage updated to <strong>Won</strong> or <strong>Active Client</strong>') 1072 + checkItem('Any punch list items resolved') 1073 + '</div></div>' 1074 } 1075 ]; 1076 1077 return '<div style="padding:20px;background:var(--d1);min-height:100vh;">' 1078 + '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px;">' 1079 + '<h2 style="color:var(--t);margin:0;">📚 Portal Tutorial & Help</h2></div>' 1080 + '<p style="color:var(--t2);margin-bottom:24px;font-size:14px;">Step-by-step guides for every section of the Jamail Hardwoods portal.</p>' 1081 + sections.map(function(s) { 1082 return '<div id="' + s.id + '" style="background:var(--d3);border:1px solid var(--b);border-radius:8px;padding:20px;margin-bottom:16px;">' 1083 + '<h3 style="color:var(--t);margin:0 0 16px 0;font-size:16px;">' + s.icon + ' ' + s.title + '</h3>' 1084 + s.content + '</div>'; 1085 }).join('') 1086 + '</div>'; 1087 } 1088 1089 function stepBadge(num, label, color) { 1090 return '<div style="display:flex;align-items:center;gap:6px;padding:6px 12px;background:' + color + '22;border:1px solid ' + color + ';border-radius:20px;">' 1091 + '<span style="background:' + color + ';color:#fff;width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;">
1091' + num + '</span>' 1092 + '<span style="color:var(--t);font-size:12px;font-weight:600;">' + label + '</span></div>'; 1093 } 1094 1095 function statusTable(rows) { 1096 return '<div style="display:grid;grid-template-columns:auto 1fr;gap:8px 16px;margin-top:8px;">' 1097 + rows.map(function(r) { 1098 return '<div style="font-weight:600;color:var(--t);font-size:13px;">' + r[0] + '</div>' 1099 + '<div style="color:var(--t2);font-size:13px;">' + r[1] + '</div>'; 1100 }).join('') 1101 + '</div>'; 1102 } 1103 1104 function checkItem(text) { 1105 return '<div style="display:flex;align-items:center;gap:10px;color:var(--t2);font-size:14px;">' 1106 + '<span style="color:var(--gold);font-size:18px;">☐</span>' + text + '</div>'; 1107 } 1108 1109 // Initialize when DOM is ready 1110 if (document.readyState === 'loading') { 1111 document.addEventListener('DOMContentLoaded', init); 1112 } else { 1113 setTimeout(init, 300); 1114 } 1115 1116 console.log('[patch.js] FIX 12: Tutorial/Help section added'); 1117})(); 1118 1119// === FIX 13: Finance Hub === 1120(function() { 1121 'use strict'; 1122 1123 // ===== Utility Functions ===== 1124 window.fmtMoney = function(n) { 1125 if (!n && n !== 0) return '0.00'; 1126 return parseFloat(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 1127 }; 1128 1129 // (Cowork hardening 2026-05-08) Date parser must treat YYYY-MM-DD strings 1130 // as LOCAL dates, not UTC. JavaScript's `new Date('2026-05-08')` parses 1131 // as UTC midnight, which renders as May 7 in any UTC-negative timezone 1132 // (Houston is UTC-5/-6). This caused payment dates to display one day 1133 // earlier than the actual payment date â Joey caught Samin's $242.24 1134 // commission showing 5/7/26 when the Excel sheet recorded it as 5/8/26. 1135 window.fmtDate = function(d) { 1136 if (!d) return ''; 1137 // Pure date string YYYY-MM-DD â parse as local, no timezone shift 1138 if (typeof d === 'string') { 1139 var ds = d.slice(0, 10); 1140 var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ds); 1141 if (m) return m[2] + '/' + m[3] + '/' + m[1]; 1142 } 1143 // Datetime with time component â let Date handle it (timestamps from 1144 // created_at etc. are already timezone-aware) 1145 var date = new Date(d); 1146 return (date.getMonth() + 1).toString().padStart(2, '0') + '/' + 1147 date.getDate().toString().padStart(2, '0') + '/' + 1148 date.getFullYear(); 1149 }; 1150 1151 window.fmtDateISO = function(d) { 1152 if (!d) return ''; 1153 if (typeof d === 'string') { 1154 var ds = d.slice(0, 10); 1155 if (/^\d{4}-\d{2}-\d{2}$/.test(ds)) return ds; 1156 } 1157 var date = new Date(d); 1158 return date.getFullYear() + '-' + 1159 (date.getMonth() + 1).toString().padStart(2, '0') + '-' + 1160 date.getDate().toString().padStart(2, '0'); 1161 }; 1162 1163 // ===== Finance Hub Rendering ===== 1164 window.renderFinanceHub = function() { 1165 if (prof.role !== 'admin') { 1166 document.getElementById('tab-finance').innerHTML = '<div style="padding:20px; color:var(--t);">Finance Hub is admin-only. Contact an administrator.</div>'; 1167 return; 1168 } 1169 1170 var html = ` 1171 <div style="display:flex; flex-direction:column; height:100%; background:var(--d1);"> 1172 <!-- Finance Sub-Nav --> 1173 <div style="padding:15px; border-bottom:1px solid var(--b); background:var(--d3); display:flex; gap:10px; flex-wrap:wrap;"> 1174 <button id="fin-btn-commission" onclick="showFinanceSection('commission')" style="padding:8px 16px; border:1px solid var(--b); background:var(--gold); color:#fff; cursor:pointer; border-radius:4px; font-size:14px; font-weight:600;">Commission Dashboard</button> 1175 <button id="fin-btn-payroll" onclick="showFinanceSection('payroll')" style="padding:8px 16px; border:1px solid var(--b); background:var(--d3); color:var(--t); cursor:pointer; border-radius:4px; font-size:14px;">Payroll</button> 1176 <button id="fin-btn-installer" onclick="showFinanceSection('installer')" style="padding:8px 16px; border:1px solid var(--b); background:var(--d3); color:var(--t); cursor:pointer; border-radius:4px; font-size:14px;">Installer Payments</button> 1177 <button id="fin-btn-vendor" onclick="showFinanceSection('vendor')" style="padding:8px 16px; border:1px solid var(--b); background:var(--d3); color:var(--t); cursor:pointer; border-radius:4px; font-size:14px;">
1177Vendor Payments</button> 1178 <button id="fin-btn-deductions" onclick="showFinanceSection('deductions')" style="padding:8px 16px; border:1px solid var(--b); background:var(--d3); color:var(--t); cursor:pointer; border-radius:4px; font-size:14px;">Deductions</button> 1179 <button id="fin-btn-credits" onclick="showFinanceSection('credits')" style="padding:8px 16px; border:1px solid var(--b); background:var(--d3); color:var(--t); cursor:pointer; border-radius:4px; font-size:14px;">Store Credits</button> 1180 <button id="fin-btn-refunds" onclick="showFinanceSection('refunds')" style="padding:8px 16px; border:1px solid var(--b); background:var(--d3); color:var(--t); cursor:pointer; border-radius:4px; font-size:14px;">Client Refunds</button> 1181 <button id="fin-btn-storage" onclick="showFinanceSection('storage')" style="padding:8px 16px; border:1px solid var(--b); background:var(--d3); color:var(--t); cursor:pointer; border-radius:4px; font-size:14px;">Storage Fees</button> 1182 <button id="fin-btn-expenses" onclick="showFinanceSection('expenses')" style="padding:8px 16px; border:1px solid var(--b); background:var(--d3); color:var(--t); cursor:pointer; border-radius:4px; font-size:14px;">Office Expenses</button> 1183 </div> 1184 1185 <!-- Content Area --> 1186 <div style="flex:1; overflow-y:auto; padding:20px;"> 1187 <!-- Commission Dashboard --> 1188 <div id="fin-sec-commission" style="display:block;"> 1189 <h2 style="color:var(--t); margin-bottom:20px;">Commission Dashboard</h2> 1190 <div id="fin-commission-content" style="color:var(--t);">Loading...</div> 1191 </div> 1192 1193 <!-- Payroll --> 1194 <div id="fin-sec-payroll" style="display:none;"> 1195 <h2 style="color:var(--t); margin-bottom:20px;">Payroll</h2> 1196 <div id="fin-payroll-content" style="color:var(--t);">Loading...</div> 1197 </div> 1198 1199 <!-- Installer Payments --> 1200 <div id="fin-sec-installer" style="display:none;"> 1201 <h2 style="color:var(--t); margin-bottom:20px;">Installer Payments</h2> 1202 <button onclick="toggleFinForm('installer-add')" style="padding:8px 16px; margin-bottom:15px; background:var(--gold); color:#fff; border:none; cursor:pointer; border-radius:4px;">+ Add Payment</button> 1203 <div id="fin-installer-form" style="display:none; margin-bottom:20px; padding:15px; background:var(--d3); border:1px solid var(--b); border-radius:4px;"></div> 1204 <div id="fin-installer-content" style="color:var(--t);">Loading...</div> 1205 </div> 1206 1207 <!-- Vendor Payments --> 1208 <div id="fin-sec-vendor" style="display:none;"> 1209 <h2 style="color:var(--t); margin-bottom:20px;">Vendor Payments</h2> 1210 <button onclick="toggleFinForm('vendor-add')" style="padding:8px 16px; margin-bottom:15px; background:var(--gold); color:#fff; border:none; cursor:pointer; border-radius:4px;">+ Add Payment</button> 1211 <div id="fin-vendor-form" style="display:none; margin-bottom:20px; padding:15px; background:var(--d3); border:1px solid var(--b); border-radius:4px;"></div> 1212 <div id="fin-vendor-content" style="color:var(--t);">Loading...</div> 1213 </div> 1214 1215 <!-- Deductions --> 1216 <div id="fin-sec-deductions" style="display:none;"> 1217 <h2 style="color:var(--t); margin-bottom:20px;">Deductions</h2> 1218 <button onclick="toggleFinForm('deductions-add')" style="padding:8px 16px; margin-bottom:15px; background:var(--gold); color:#fff; border:none; cursor:pointer; border-radius:4px;">+ Add Deduction</button> 1219 <div id="fin-deductions-form" style="display:none; margin-bottom:20px; padding:15px; background:var(--d3); border:1px solid var(--b); border-radius:4px;"></div> 1220 <div id="fin-deductions-content" style="color:var(--t);">Loading...</div> 1221 </div> 1222 1223 <!-- Store Credits --> 1224 <div id="fin-sec-credits" style="display:none;"> 1225 <h2 style="color:var(--t); margin-bottom:20px;">Store Credits</h2> 1226 <button onclick="toggleFinForm('credits-add')" style="padding:8px 16px; margin-bottom:15px; background:var(--gold); color:#fff; border:none; cursor:pointer; border-radius:4px;">+ Add Credit</button> 1227 <div id="fin-credits-form" style="display:none; margin-bottom:20px; padding:15px; background:var(--d3); border:1px solid var(--b); border-radius:4px;"></div> 1228 <div id="fin-credits-content" style="color:var(--t);">Loading...</div> 1229 </div> 1230 1231 <!-- Client Refunds --> 1232 <div id="fin-sec-refunds" style="display:none;"> 1233 <h2 style="color:var(--t); margin-bottom:20px;">Client Refunds</h2> 1234 <button onclick="toggleFinForm('refunds-add')" style="padding:8px 16px; margin-bottom:15px; background:var(--gold); color:#fff; border:none; cursor:pointer; border-radius:4px;">+ Add Refund</button> 1235 <div id="fin-refunds-form" style="display:none; margin-bottom:20px; padding:15px; background:var(--d3); border:1px solid var(--b); border-radius:4px;"></div> 1236 <div id="fin-refunds-content" style="color:var(--t);">Loading...</div> 1237 </div> 1238 1239 <!-- Storage Fees --> 1240 <div id="fin-sec-storage" style="display:none;"> 1241 <h2 style="color:var(--t); margin-bottom:20px;">Storage Fees</h2> 1242 <button onclick="toggleFinForm('storage-add')" style="padding:8px 16px; margin-bottom:15px; background:var(--gold); color:#fff; border:none; cursor:pointer; border-radius:4px;">+ Add Storage Fee</button> 1243 <div id="fin-storage-form" style="display:none; margin-bottom:20px; padding:15px; background:var(--d3); border:1px solid var(--b); border-radius:4px;"></div> 1244 <div id="fin-storage-content" style="color:var(--t);">Loading...</div> 1245 </div> 1246 1247 <!-- Office Expenses --> 1248 <div id="fin-sec-expenses" style="display:none;"> 1249 <h2 style="color:var(--t); margin-bottom:20px;">Office Expenses</h2> 1250 <button onclick="toggleFinForm('expenses-add')" style="padding:8px 16px; margin-bottom:15px; background:var(--gold); color:#fff; border:none; cursor:pointer; border-radius:4px;">+ Add Expense</button> 1251 <div id="fin-expenses-form" style="display:none; margin-bottom:20px; padding:15px; background:var(--d3); border:1px solid var(--b); border-radius:4px;"></div> 1252 <div id="fin-expenses-content" style="color:var(--t);">Loading...</div> 1253 </div> 1254 </div> 1255 </div> 1256 `; 1257 document.getElementById('tab-finance').innerHTML = html; 1258 showFinanceSection('commission'); 1259 }; 1260 1261 // ===== Section Navigation ===== 1262 window.showFinanceSection = function(section) { 1263 // Hide all sections 1264 document.querySelectorAll('[id^="fin-sec-"]').forEach(function(d) { 1265 d.style.display = 'none'; 1266 }); 1267 // Show selected 1268 var el = document.getElementById('fin-sec-' + section); 1269 if (el) el.style.display = 'block'; 1270 1271 // Update button styles 1272 document.querySelectorAll('[id^="fin-btn-"]').forEach(function(b) { 1273 b.style.background = 'var(--d3)'; 1274 b.style.color = 'var(--t)'; 1275 b.style.borderColor = 'var(--b)'; 1276 }); 1277 var btn = document.getElementById('fin-btn-' + section); 1278 if (btn) { 1279 btn.style.background = 'var(--gold)'; 1280 btn.style.color = '#fff'; 1281 btn.style.borderColor = 'var(--gold)'; 1282 } 1283 1284 // Load data 1285 loadFinanceSection(section); 1286 }; 1287 1288 window.setFinDateRange = function(range) { 1289 _finDateRange = range; 1290 loadCommissionDashboard(); 1291 }; 1292 1293 window.loadFinanceSection = function(section) { 1294 if (section === 'commission') loadCommissionDashboard(); 1295 else if (section === 'payroll') loadPayrollSection();
1296 else if (section === 'installer') loadInstallerPayments(); 1297 else if (section === 'vendor') loadVendorPayments(); 1298 else if (section === 'deductions') loadDeductions(); 1299 else if (section === 'credits') loadStoreCredits(); 1300 else if (section === 'refunds') loadClientRefunds(); 1301 else if (section === 'storage') loadStorageFees(); 1302 else if (section === 'expenses') loadOfficeExpenses(); 1303 }; 1304 1305 // ===== Commission Dashboard ===== 1306 // Track the selected date range for commission filtering 1307 var _finDateRange = 'ytd'; // default to YTD 2026 1308 1309 window.loadCommissionDashboard = function() { 1310 var contentEl = document.getElementById('fin-commission-content'); 1311 contentEl.innerHTML = 'Loading...'; 1312 1313 // Calculate date range based on selection 1314 var now = new Date(); 1315 var year = 2026; 1316 var rangeStart, rangeEnd, rangeLabel; 1317 1318 if (_finDateRange === 'ytd') { 1319 rangeStart = year + '-01-01'; 1320 rangeEnd = fmtDateISO(now); 1321 rangeLabel = '2026 Year-to-Date (Jan 1 â Today)'; 1322 } else if (_finDateRange === 'full') { 1323 rangeStart = year + '-01-01'; 1324 rangeEnd = year + '-12-31'; 1325 rangeLabel = 'Full Year 2026'; 1326 } else if (_finDateRange === 'q1') { 1327 rangeStart = year + '-01-01'; rangeEnd = year + '-03-31'; 1328 rangeLabel = 'Q1 2026 (JanâMar)'; 1329 } else if (_finDateRange === 'q2') { 1330 rangeStart = year + '-04-01'; rangeEnd = year + '-06-30'; 1331 rangeLabel = 'Q2 2026 (AprâJun)'; 1332 } else if (_finDateRange === 'q3') { 1333 rangeStart = year + '-07-01'; rangeEnd = year + '-09-30'; 1334 rangeLabel = 'Q3 2026 (JulâSep)'; 1335 } else if (_finDateRange === 'q4') { 1336 rangeStart = year + '-10-01'; rangeEnd = year + '-12-31'; 1337 rangeLabel = 'Q4 2026 (OctâDec)'; 1338 } else if (_finDateRange === 'month') { 1339 var monthStart = new Date(now.getFullYear(), now.getMonth(), 1); 1340 var monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); 1341 rangeStart = fmtDateISO(monthStart); 1342 rangeEnd = fmtDateISO(monthEnd); 1343 rangeLabel = 'This Month (' + monthStart.toLocaleString('default',{month:'long'}) + ' ' + now.getFullYear() + ')'; 1344 } else { 1345 rangeStart = year + '-01-01'; 1346 rangeEnd = fmtDateISO(now); 1347 rangeLabel = '2026 Year-to-Date'; 1348 } 1349 1350 // (Cowork hardening 2026-05-08) Proper multi-rep commission split 1351 // --------------------------------------------------------------- 1352 // OLD behavior: grouped invoices by raw `sales_rep` text. When an 1353 // invoice had two reps (e.g., "Patrick McNeill, Samin Seifpour"), 1354 // the comma-joined string was treated as a single rep name and 1355 // never matched rep_pay_config â so the deal showed $0 commission 1356 // even though both reps deserved a cut. Joey's data was missing 1357 // ~$28k of commission that way. 1358 // 1359 // NEW behavior: per-invoice allocation using the underlying 1360 // quote's commission_pct + second_rep_commission_pct. Falls back 1361 // to rep_pay_config when there's no quote. 1362 // 1363 // Commission base = max(0, wo_profit - tax_amount). Per Joey: 1364 // sales tax is not profit and shouldn't earn commission. The 1365 // existing wo_profit field includes tax in the calc; we strip 1366 // it out here so historical data computes correctly without a 1367 // backfill of every invoice's wo_profit field. 1368 Promise.all([ 1369 sb.from('invoices').select('id,invoice_number,quote_id,sales_rep,total_sale,wo_profit,wo_net_profit,tax_amount').gte('created_date', rangeStart).lte('created_date', rangeEnd), 1370 sb.from('rep_pay_config').select('*'), 1371 sb.from('quotes').select('id,commission_pct,second_rep_commission_pct'), 1372 sb.from('rep_deductions').select('rep_name,amount,paid_amount,status').gte('deduction_date', rangeStart).lte('deduction_date', rangeEnd), 1373 // (Cowork hardening 2026-05-08) Pull commission payments in the same date 1374 // range so the dashboard can show Earned vs Paid vs Outstanding per rep. 1375 sb.from('rep_payments').select('rep_name,amount,payment_type').eq('payment_type', 'commission').gte('payment_date', rangeStart).lte('payment_date', rangeEnd) 1376 ]).then(function(results) { 1377 var invoices = results[0].data || []; 1378 var repConfigs = results[1].data || []; 1379 var quotes = results[2].data || []; 1380 var deductions = results[3].data || []; 1381 var commissionPayments = results[4].data || []; 1382 1383 // Lookups 1384 var quoteById = {};
1385 quotes.forEach(function(q) { quoteById[q.id] = q; }); 1386 var configByName = {}; 1387 repConfigs.forEach(function(c) { configByName[c.rep_name] = c; }); 1388 1389 var repData = {}; // keyed by individual rep name 1390 1391 function bumpRep(name, deltaSales, deltaProfitBase, deltaCommission, ratePct) { 1392 if (!name) return; 1393 if (!repData[name]) { 1394 repData[name] = { deals: 0, totalSale: 0, profitBase: 0, commission: 0, deductions: 0, _rateSum: 0, _rateN: 0, config: configByName[name] || null }; 1395 } 1396 repData[name].deals += 1; 1397 repData[name].totalSale += deltaSales; 1398 repData[name].profitBase += deltaProfitBase; 1399 repData[name].commission += deltaCommission; 1400 repData[name]._rateSum += ratePct; 1401 repData[name]._rateN += 1; 1402 } 1403 1404 invoices.forEach(function(inv) { 1405 var sr = (inv.sales_rep || '').trim(); 1406 var reps = sr ? sr.split(',').map(function(s) { return s.trim(); }).filter(Boolean) : []; 1407 if (reps.length === 0) reps = ['Unknown']; 1408 var saleTotal = parseFloat(inv.total_sale || 0); 1409 var profitInclTax = parseFloat(inv.wo_profit || inv.wo_net_profit || 0); 1410 var tax = parseFloat(inv.tax_amount || 0); 1411 // Strip tax out of the commission base â tax is not profit. 1412 var commissionBase = Math.max(0, profitInclTax - tax); 1413 1414 var quote = inv.quote_id ? quoteById[inv.quote_id] : null; 1415 var firstPct = 0, secondPct = 0; 1416 if (quote && (quote.commission_pct || quote.second_rep_commission_pct)) { 1417 firstPct = parseFloat(quote.commission_pct || 0); 1418 secondPct = parseFloat(quote.second_rep_commission_pct || 0); 1419 } else if (configByName[reps[0]]) { 1420 var rawPct = parseFloat(configByName[reps[0]].commission_pct || 25); 1421 firstPct = rawPct > 1 ? rawPct : rawPct * 100; 1422 } else { 1423 firstPct = 25; // default 1424 } 1425 // Normalize fraction-style percents (0.25 â 25) 1426 if (firstPct > 0 && firstPct < 1) firstPct *= 100; 1427 if (secondPct > 0 && secondPct < 1) secondPct *= 100; 1428 1429 // Allocate proportionally so sales/profit totals match invoice totals 1430 var totalPct = firstPct + (reps.length > 1 ? secondPct : 0); 1431 var firstSalesShare, secondSalesShare; 1432 if (totalPct > 0) { 1433 firstSalesShare = firstPct / totalPct; 1434 secondSalesShare = secondPct / totalPct; 1435 } else { 1436 firstSalesShare = 1; secondSalesShare = 0; 1437 } 1438 1439 var firstCommission = commissionBase * (firstPct / 100); 1440 var secondCommission = commissionBase * (secondPct / 100); 1441 bumpRep(reps[0], saleTotal * firstSalesShare, commissionBase * firstSalesShare, firstCommission, firstPct); 1442 if (reps.length > 1) { 1443 bumpRep(reps[1], saleTotal * secondSalesShare, commissionBase * secondSalesShare, secondCommission, secondPct); 1444 } 1445 }); 1446 1447 // Apply rep_deductions in date range â but ONLY count what's been 1448 // actually deducted (status='paid' = full amount; status='partial' = 1449 // paid_amount only; status='new' = informational, not yet taken). 1450 deductions.forEach(function(d) { 1451 if (!d.rep_name || !repData[d.rep_name]) return; 1452 var st = d.status || 'new'; 1453 var amt = parseFloat(d.amount || 0); 1454 var paidAmt = parseFloat(d.paid_amount || 0); 1455 var deducted = (st === 'paid') ? amt : (st === 'partial' ? paidAmt : 0); 1456 repData[d.rep_name].deductions += deducted; 1457 }); 1458 1459 // Apply commission payments â sum what's been paid in the same period 1460 commissionPayments.forEach(function(p) { 1461 if (!p.rep_name || !repData[p.rep_name]) return; 1462 if (!repData[p.rep_name].commissionPaid) repData[p.rep_name].commissionPaid = 0; 1463 repData[p.rep_name].commissionPaid += parseFloat(p.amount || 0); 1464 }); 1465 1466 // Date range selector + summary header 1467 var totalSales = 0, totalProfit = 0, totalCommission = 0, totalDeductions = 0, totalCommPaid = 0, totalDeals = 0; 1468 Object.keys(repData).forEach(function(r) { 1469 totalSales += repData[r].totalSale; 1470 totalProfit += repData[r].profitBase; 1471 totalCommission += repData[r].commission; 1472 totalDeductions += repData[r].deductions; 1473 totalCommPaid += (repData[r].commissionPaid || 0); 1474 totalDeals += repData[r].deals; 1475 }); 1476 1477 var html = '<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; flex-wrap:wrap; gap:10px;">'; 1478 html += '<div style="font-size:14px; color:var(--t2); font-weight:600;">' + rangeLabel + ' — ' + invoices.length + ' invoices</div>'; 1479 html += '<select id="fin-date-range" onchange="setFinDateRange(this.value)" style="padding:8px 12px; background:var(--d3); border:1px solid var(--b); color:var(--t); border-radius:6px; font-size:13px;">'; 1480 html += '<option value="ytd"' + (_finDateRange==='ytd'?' selected':'') + '>2026 YTD</option>'; 1481 html += '<option value="full"' + (_finDateRange==='full'?' selected':'') + '>Full Year 2026</option>'; 1482 html += '<option value="q1"' + (_finDateRange==='q1'?' selected':'') + '>Q1 (Jan-Mar)</option>'; 1483 html += '<option value="q2"' + (_finDateRange==='q2'?' selected':'') + '>Q2 (Apr-Jun)</option>'; 1484 html += '<option value="q3"' + (_finDateRange==='q3'?' selected':'') + '>Q3 (Jul-Sep)</option>'; 1485 html += '<option value="q4"' + (_finDateRange==='q4'?' selected':'') + '>Q4 (Oct-Dec)</option>'; 1486 html += '<option value="month"' + (_finDateRange==='month'?' selected':'') + '>This Month</option>'; 1487 html += '</select></div>'; 1488 1489 var netCommission = totalCommission - totalDeductions; 1490 1491 // KPI summary cards (5 now, including Deductions) 1492 html += '<div style="display:grid; grid-template-columns:repeat(5,1fr); gap:12px; margin-bottom:20px;">'; 1493 html += '<div style="background:var(--d3); border:1px solid var(--b); border-radius:8px; padding:16px; text-align:center;">' 1494 + '<div style="font-size:10px; color:var(--t3); text-transform:uppercase; font-weight:600;">Total Deals</div>' 1495 + '<div style="font-size:22px; font-weight:700; color:var(--t); margin-top:4px;">' + totalDeals + '</div></div>'; 1496 html += '<div style="background:var(--d3); border:1px solid var(--b); border-radius:8px; padding:16px; text-align:center;">' 1497 + '<div style="font-size:10px; color:var(--t3); text-transform:uppercase; font-weight:600;">Total Sales</div>' 1498 + '<div style="font-size:22px; font-weight:700; color:var(--t); margin-top:4px;">' + '$' + fmtMoney(totalSales) + '</div></div>'; 1499 html += '<div style="background:var(--d3); border:1px solid var(--b); border-radius:8px; padding:16px; text-align:center;">' 1500 + '<div style="font-size:10px; color:var(--t3); text-transform:uppercase; font-weight:600;">Profit (ex tax)</div>' 1501 + '<div style="font-size:22px; font-weight:700; color:' + (totalProfit > 0 ? '#16a34a' : '#ef4444') + '; margin-top:4px;">' + '$' + fmtMoney(totalProfit) + '</div></div>'; 1502 html += '<div style="background:var(--d3); border:1px solid var(--b); border-radius:8px; padding:16px; text-align:center;">' 1503 + '<div style="font-size:10px; color:var(--t3); text-transform:uppercase; font-weight:600;">Commission Gross</div>' 1504 + '<div style="font-size:22px; font-weight:700; color:var(--gold); margin-top:4px;">' + '$' + fmtMoney(totalCommission) + '</div>' 1505 + '<div style="font-size:10px; color:#ef4444; margin-top:2px;">−$' + fmtMoney(totalDeductions) + ' ded.</div></div>'; 1506 html += '<div style="background:var(--d3); border:2px solid var(--gold); border-radius:8px; padding:16px; text-align:center;">' 1507 + '<div style="font-size:10px; color:var(--gold); text-transform:uppercase; font-weight:700;">Net Commission</div>' 1508 + '<div style="font-size:24px; font-weight:800; color:var(--gold); margin-top:4px;">' + '$' + fmtMoney(netCommission) + '</div></div>'; 1509 html += '</div>'; 1510 1511 html += '<table style="width:100%; border-collapse:collapse; margin-top:10px;">'; 1512 html += '<tr style="background:var(--d3); border-bottom:1px solid var(--b);">'; 1513 html += '<th style="padding:10px; text-align:left; color:var(--t);">Rep Name</th>'; 1514 html += '<th style="padding:10px; text-align:right; color:var(--t);">Deals</th>'; 1515 html += '<th style="padding:10px; text-align:right; color:var(--t);">Sales</th>'; 1516 html += '<th style="padding:10px; text-align:right; color:var(--t);">Profit (ex tax)</th>'; 1517 html += '<th style="padding:10px; text-align:right; color:var(--t);">Avg Rate</th>'; 1518 html += '<th style="padding:10px; text-align:right; color:var(--t);">Commission Earned</th>'; 1519 html += '<th style="padding:10px; text-align:right; color:var(--t);">Deductions</th>'; 1520 html += '<th style="padding:10px; text-align:right; color:#16a34a;">Paid</th>'; 1521 html += '<th style="padding:10px; text-align:right; color:var(--gold);">Outstanding</th>'; 1522 html += '</tr>'; 1523 1524 var repNamesSorted = Object.keys(repData).sort(function(a, b) { 1525 var aOut = (repData[a].commission - repData[a].deductions) - (repData[a].commissionPaid || 0); 1526 var bOut = (repData[b].commission - repData[b].deductions) - (repData[b].commissionPaid || 0); 1527 return bOut - aOut; 1528 }); 1529 repNamesSorted.forEach(function(repName) { 1530 var data = repData[repName]; 1531 var avgRate = data._rateN > 0 ? (data._rateSum / data._rateN).toFixed(1) + '%' : 'â'; 1532 var paid = data.commissionPaid || 0; 1533 var owed = data.commission - data.deductions; // net earned 1534 var outstanding = owed - paid; 1535 html += '<tr style="border-bottom:1px solid var(--b);">'; 1536 html += '<td style="padding:10px; color:var(--t);">
1536' + repName + '</td>'; 1537 html += '<td style="padding:10px; text-align:right; color:var(--t);">' + data.deals + '</td>'; 1538 html += '<td style="padding:10px; text-align:right; color:var(--t);">$' + fmtMoney(data.totalSale) + '</td>'; 1539 html += '<td style="padding:10px; text-align:right; color:' + (data.profitBase > 0 ? '#16a34a' : 'var(--t)') + ';">$' + fmtMoney(data.profitBase) + '</td>'; 1540 html += '<td style="padding:10px; text-align:right; color:var(--t3); font-size:12px;">' + avgRate + '</td>'; 1541 html += '<td style="padding:10px; text-align:right; color:var(--gold); font-weight:600;">$' + fmtMoney(data.commission) + '</td>'; 1542 html += '<td style="padding:10px; text-align:right; color:' + (data.deductions > 0 ? '#ef4444' : 'var(--t3)') + ';">' + (data.deductions > 0 ? 'â$' + fmtMoney(data.deductions) : '$0') + '</td>'; 1543 html += '<td style="padding:10px; text-align:right; color:#16a34a;">$' + fmtMoney(paid) + '</td>'; 1544 html += '<td style="padding:10px; text-align:right; color:' + (outstanding > 0.01 ? 'var(--gold)' : (outstanding < -0.01 ? '#3b82f6' : 'var(--t3)')) + '; font-weight:700;">' + (outstanding < 0 ? '$(' + fmtMoney(-outstanding) + ')' : '$' + fmtMoney(outstanding)) + '</td>'; 1545 html += '</tr>'; 1546 }); 1547 1548 html += '</table>'; 1549 html += '<div style="margin-top:14px; padding:12px 16px; background:var(--d3); border-left:3px solid var(--gold); border-radius:6px; font-size:12px; color:var(--t3); line-height:1.6;">'; 1550 html += '<strong style="color:var(--gold);">How this is computed:</strong> Per-deal commission split using each quote\u2019s <code>commission_pct</code> + <code>second_rep_commission_pct</code> (handles unequal splits). Commission base = <strong>profit minus sales tax</strong> (per Joey: tax is not profit). Deductions are pulled from Finance Hub \u2192 Deductions for the same date range. Falls back to <code>rep_pay_config</code> rate when an invoice has no quote attached.'; 1551 html += '</div>'; 1552 contentEl.innerHTML = html; 1553 }).catch(function(err) { 1554 console.error('Commission dashboard error:', err); 1555 contentEl.innerHTML = '<div style="color:var(--t2);">Error loading commission data</div>'; 1556 }); 1557 }; 1558 1559 // ===== Payroll Section ===== 1560 // (Cowork hardening 2026-05-08) Payroll tab now shows actual rep_payments 1561 // history (salary + commission + bonus + reimbursement + adjustment), 1562 // backed by the FloorZap import (229 commission + 17 salary = 246 rows 1563 // through 2024â2026). Replaces the unused rep_payroll placeholder. 1564 window._finPayrollFilter = window._finPayrollFilter || { rep: 'all', type: 'all', dateRange: 'ytd' }; 1565 1566 window.loadPayrollSection = function() { 1567 var contentEl = document.getElementById('fin-payroll-content'); 1568 contentEl.innerHTML = 'Loading...'; 1569 1570 var f = window._finPayrollFilter; 1571 var now = new Date(); 1572 var rangeStart, rangeEnd; 1573 if (f.dateRange === 'ytd') { rangeStart = '2026-01-01'; rangeEnd = fmtDateISO(now); } 1574 else if (f.dateRange === 'last30') { var d=new Date(); d.setDate(d.getDate()-30); rangeStart=fmtDateISO(d); rangeEnd=fmtDateISO(now); } 1575 else if (f.dateRange === 'q1') { rangeStart='2026-01-01'; rangeEnd='2026-03-31'; } 1576 else if (f.dateRange === 'q2') { rangeStart='2026-04-01'; rangeEnd='2026-06-30'; } 1577 else if (f.dateRange === 'q3') { rangeStart='2026-07-01'; rangeEnd='2026-09-30'; } 1578 else if (f.dateRange === 'q4') { rangeStart='2026-10-01'; rangeEnd='2026-12-31'; } 1579 else if (f.dateRange === '2025') { rangeStart='2025-01-01'; rangeEnd='2025-12-31'; } 1580 else if (f.dateRange === '2024') { rangeStart='2024-01-01'; rangeEnd='2024-12-31'; } 1581 else { rangeStart = '2024-01-01'; rangeEnd = fmtDateISO(now); } 1582 1583 var query = sb.from('rep_payments').select('*').gte('payment_date', rangeStart).lte('payment_date', rangeEnd).order('payment_date', { ascending: false }); 1584 if (f.rep !== 'all') query = query.eq('rep_name', f.rep); 1585 if (f.type !== 'all') query = query.eq('payment_type', f.type); 1586 1587 Promise.all([ 1588 query, 1589 sb.from('rep_payments').select('rep_name') // for the rep dropdown 1590 ]).then(function(results) { 1591 var payments = results[0].data || []; 1592 var allReps = {};
1593 (results[1].data || []).forEach(function(r) { if (r.rep_name) allReps[r.rep_name] = true; }); 1594 var reps = Object.keys(allReps).sort(); 1595 1596 // Aggregate per rep (filtered set) 1597 var perRep = {}; 1598 payments.forEach(function(p) { 1599 var n = p.rep_name || 'Unknown'; 1600 if (!perRep[n]) perRep[n] = { total: 0, salary: 0, commission: 0, bonus: 0, reimb: 0, count: 0 }; 1601 var amt = parseFloat(p.amount || 0); 1602 perRep[n].total += amt; 1603 perRep[n].count += 1; 1604 if (p.payment_type === 'salary') perRep[n].salary += amt; 1605 else if (p.payment_type === 'commission') perRep[n].commission += amt; 1606 else if (p.payment_type === 'bonus') perRep[n].bonus += amt; 1607 else if (p.payment_type === 'reimbursement') perRep[n].reimb += amt; 1608 }); 1609 1610 // Grand totals 1611 var gTotal = 0, gSalary = 0, gComm = 0; 1612 Object.keys(perRep).forEach(function(n) { 1613 gTotal += perRep[n].total; gSalary += perRep[n].salary; gComm += perRep[n].commission; 1614 }); 1615 1616 var html = ''; 1617 1618 // Header + Add Payment button 1619 html += '<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:14px; flex-wrap:wrap; gap:10px;">'; 1620 html += '<h3 style="margin:0; color:var(--t);">Sales Rep Payment History</h3>'; 1621 html += '<button onclick="openPayrollAddForm()" style="padding:8px 16px; background:var(--gold); color:#fff; border:none; cursor:pointer; border-radius:6px; font-size:13px; font-weight:600;">+ Record Payment</button>'; 1622 html += '</div>'; 1623 1624 // Filters 1625 html += '<div style="display:flex; gap:10px; align-items:center; margin-bottom:14px; flex-wrap:wrap;">'; 1626 html += '<label style="font-size:11px; color:var(--t3); text-transform:uppercase; font-weight:700;">Rep:</label>'; 1627 html += '<select onchange="setFinPayrollFilter(\'rep\', this.value)" style="padding:6px 12px; background:var(--d3); border:1px solid var(--b); color:var(--t); border-radius:6px; font-size:12px;">'; 1628 html += '<option value="all"' + (f.rep==='all'?' selected':'') + '>All Reps</option>'; 1629 reps.forEach(function(rn) { html += '<option value="' + rn.replace(/"/g,'"') + '"' + (f.rep===rn?' selected':'') + '>' + rn + '</option>'; }); 1630 html += '</select>'; 1631 html += '<label style="font-size:11px; color:var(--t3); text-transform:uppercase; font-weight:700; margin-left:6px;">Type:</label>'; 1632 html += '<select onchange="setFinPayrollFilter(\'type\', this.value)" style="padding:6px 12px; background:var(--d3); border:1px solid var(--b); color:var(--t); border-radius:6px; font-size:12px;">'; 1633 ['all','salary','commission','bonus','reimbursement','deduction','adjustment'].forEach(function(t) { 1634 html += '<option value="' + t + '"' + (f.type===t?' selected':'') + '>' + t.toUpperCase() + '</option>'; 1635 }); 1636 html += '</select>'; 1637 html += '<label style="font-size:11px; color:var(--t3); text-transform:uppercase; font-weight:700; margin-left:6px;">Period:</label>'; 1638 html += '<select onchange="setFinPayrollFilter(\'dateRange\', this.value)" style="padding:6px 12px; background:var(--d3); border:1px solid var(--b); color:var(--t); border-radius:6px; font-size:12px;">'; 1639 [['ytd','2026 YTD'],['last30','Last 30 Days'],['q1','Q1 2026'],['q2','Q2 2026'],['q3','Q3 2026'],['q4','Q4 2026'],['2025','All 2025'],['2024','All 2024'],['all','All Time']].forEach(function(r) { 1640 html += '<option value="' + r[0] + '"' + (f.dateRange===r[0]?' selected':'') + '>' + r[1] + '</option>'; 1641 }); 1642 html += '</select>'; 1643 html += '<div style="flex:1"></div>'; 1644 html += '<div style="font-size:11px; color:var(--t3);">' + payments.length + ' payments · ' + rangeStart + ' to ' + rangeEnd + '</div>'; 1645 html += '</div>'; 1646 1647 // Per-rep summary cards 1648 html += '<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:10px; margin-bottom:18px;">'; 1649 html += '<div style="background:var(--d3); border:2px solid var(--gold); border-radius:8px; padding:14px; text-align:center;">'; 1650 html += '<div style="font-size:11px; color:var(--gold); text-transform:uppercase; font-weight:700;">Total Paid (Filtered)</div>'; 1651 html += '<div style="font-size:22px; font-weight:800; color:var(--gold); margin-top:4px;">$' + fmtMoney(gTotal) + '</div>'; 1652 html += '<div style="font-size:11px; color:var(--t3); margin-top:3px;">Salary $' + fmtMoney(gSalary) + ' · Commission $' + fmtMoney(gComm) + '</div>'; 1653 html += '</div>'; 1654 Object.keys(perRep).sort(function(a,b){ return perRep[b].total - perRep[a].total; }
1654).forEach(function(rn) { 1655 var d = perRep[rn]; 1656 html += '<div style="background:var(--d3); border:1px solid var(--b); border-radius:8px; padding:14px;">'; 1657 html += '<div style="font-size:11px; color:var(--t3); text-transform:uppercase; font-weight:700; margin-bottom:4px;">' + rn + '</div>'; 1658 html += '<div style="font-size:18px; font-weight:800; color:var(--t);">$' + fmtMoney(d.total) + '</div>'; 1659 if (d.salary > 0) html += '<div style="font-size:11px; color:var(--t3);">Salary: $' + fmtMoney(d.salary) + '</div>'; 1660 if (d.commission > 0) html += '<div style="font-size:11px; color:var(--gold);">Commission: $' + fmtMoney(d.commission) + '</div>'; 1661 if (d.bonus > 0) html += '<div style="font-size:11px; color:#10b981;">Bonus: $' + fmtMoney(d.bonus) + '</div>'; 1662 html += '<div style="font-size:10px; color:var(--t3); margin-top:3px;">' + d.count + ' payments</div>'; 1663 html += '</div>'; 1664 }); 1665 html += '</div>'; 1666 1667 // Table 1668 html += '<div style="overflow-x:auto;"><table style="width:100%; border-collapse:collapse; font-size:13px; min-width:900px;">'; 1669 html += '<thead><tr style="background:var(--d3); border-bottom:1px solid var(--b);">'; 1670 html += '<th style="padding:10px; text-align:left; color:var(--t3); font-size:11px; text-transform:uppercase;">Date</th>'; 1671 html += '<th style="padding:10px; text-align:left; color:var(--t3); font-size:11px; text-transform:uppercase;">Rep</th>'; 1672 html += '<th style="padding:10px; text-align:left; color:var(--t3); font-size:11px; text-transform:uppercase;">Type</th>'; 1673 html += '<th style="padding:10px; text-align:right; color:var(--t3); font-size:11px; text-transform:uppercase;">Amount</th>'; 1674 html += '<th style="padding:10px; text-align:left; color:var(--t3); font-size:11px; text-transform:uppercase;">Method</th>'; 1675 html += '<th style="padding:10px; text-align:left; color:var(--t3); font-size:11px; text-transform:uppercase;">From</th>'; 1676 html += '<th style="padding:10px; text-align:left; color:var(--t3); font-size:11px; text-transform:uppercase;">WO Refs / Notes</th>'; 1677 html += '<th style="padding:10px; text-align:center; color:var(--t3); font-size:11px; text-transform:uppercase;">Action</th>'; 1678 html += '</tr></thead><tbody>'; 1679 1680 if (payments.length === 0) { 1681 html += '<tr><td colspan="8" style="padding:30px; text-align:center; color:var(--t3);">No payments match the current filter.</td></tr>'; 1682 } 1683 payments.forEach(function(p) { 1684 var typeColor = { salary:'#3b82f6', commission:'#f59e0b', bonus:'#10b981', reimbursement:'#8b5cf6', deduction:'#ef4444', adjustment:'#6b7280' }[p.payment_type] || 'var(--t3)'; 1685 var typeLabel = (p.payment_type || 'unknown').toUpperCase(); 1686 html += '<tr style="border-bottom:1px solid var(--b);">'; 1687 html += '<td style="padding:10px; color:var(--t2); font-size:12px;">' + fmtDate(p.payment_date) + '</td>'; 1688 html += '<td style="padding:10px; color:var(--t); font-weight:600;">' + (p.rep_name||'') + '</td>'; 1689 html += '<td style="padding:10px;"><span style="display:inline-block; padding:2px 8px; border-radius:8px; background:' + typeColor + '22; color:' + typeColor + '; font-size:10px; font-weight:700; letter-spacing:0.5px;">' + typeLabel + '</span></td>'; 1690 html += '<td style="padding:10px; text-align:right; color:' + (p.payment_type==='deduction' ? '#ef4444' : 'var(--gold)') + '; font-weight:700;">$' + fmtMoney(p.amount) + '</td>'; 1691 html += '<td style="padding:10px; color:var(--t2); font-size:12px;">' + (p.method || '') + '</td>'; 1692 html += '<td style="padding:10px; color:var(--t3); font-size:11px;">' + (p.from_account || 'â') + '</td>'; 1693 var ref = p.wo_references ? p.wo_references.length > 50 ? p.wo_references.slice(0,47) + 'â¦' : p.wo_references : (p.notes ? (p.notes.length > 80 ? p.notes.slice(0,77) + 'â¦' : p.notes) : 'â'); 1694 html += '<td style="padding:10px; color:var(--t3); font-size:11px;" title="' + (p.notes||'').replace(/"/g,'"') + '">' + ref + '</td>'; 1695 html += '<td style="padding:10px; text-align:center;">'; 1696 html += '<button onclick="deleteFinRecord(\'rep_payments\', ' + p.id + ')" style="padding:3px 8px; background:transparent; color:#c33; border:1px solid #c33; cursor:pointer; border-radius:3px; font-size:11px;">Delete</button>'; 1697 html += '</td>'; 1698 html += '</tr>'; 1699 }); 1700 html += '</tbody></table></div>'; 1701 1702 contentEl.innerHTML = html; 1703 }).catch(function(err) { 1704 console.error('Payroll error:', err); 1705 contentEl.innerHTML = '<div style="color:var(--t2);">Error loading payroll data</div>'; 1706 }); 1707 }; 1708 1709 window.openPayrollForm = function() { 1710 // Legacy "Run Payroll" placeholder â replaced by openPayrollAddForm below.
1711 window.openPayrollAddForm(); 1712 }; 1713 1714 window.setFinPayrollFilter = function(field, value) { 1715 window._finPayrollFilter[field] = value; 1716 window.loadPayrollSection(); 1717 }; 1718 1719 // Inline modal for recording a new payment to a rep 1720 window.openPayrollAddForm = function() { 1721 var existing = document.getElementById('fin-payroll-modal'); 1722 if (existing) existing.remove(); 1723 1724 var activeReps = (typeof profs !== 'undefined' && Array.isArray(profs)) 1725 ? profs.filter(function(p){ return p && p.full_name; }).map(function(p){ return p.full_name; }).sort() 1726 : ['Ashley Brooke','Aly Champagne','Joey Jamail','Patrick McNeill','Samin Seifpour']; 1727 var repOpts = '<option value="">â Select rep â</option>' + activeReps.map(function(n){ return '<option value="' + n.replace(/"/g,'"') + '">' + n + '</option>'; }).join(''); 1728 1729 var modal = document.createElement('div'); 1730 modal.id = 'fin-payroll-modal'; 1731 modal.style.cssText = 'position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:9999; display:flex; align-items:center; justify-content:center; padding:20px;'; 1732 modal.innerHTML = 1733 '<div style="background:var(--d2); border:1px solid var(--b); border-radius:12px; padding:22px; max-width:560px; width:100%;">' + 1734 '<h3 style="margin:0 0 16px 0; color:var(--gold); font-size:16px;">Record Payment to Rep</h3>' + 1735 '<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">' + 1736 '<div><label style="font-size:11px; color:var(--t3); display:block; margin-bottom:4px; font-weight:600;">Rep *</label>' + 1737 '<select id="pay-rep" style="width:100%; padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:6px;">' + repOpts + '</select></div>' + 1738 '<div><label style="font-size:11px; color:var(--t3); display:block; margin-bottom:4px; font-weight:600;">Date *</label>' + 1739 '<input type="date" id="pay-date" value="' + new Date().toISOString().split('T')[0] + '" style="width:100%; padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:6px;"></div>' + 1740 '<div><label style="font-size:11px; color:var(--t3); display:block; margin-bottom:4px; font-weight:600;">Amount *</label>' + 1741 '<input type="number" id="pay-amount" step="0.01" placeholder="0.00" style="width:100%; padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:6px;"></div>' + 1742 '<div><label style="font-size:11px; color:var(--t3); display:block; margin-bottom:4px; font-weight:600;">Type *</label>' + 1743 '<select id="pay-type" style="width:100%; padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:6px;">' + 1744 '<option value="commission">Commission</option><option value="salary">Salary</option><option value="bonus">Bonus</option><option value="reimbursement">Reimbursement</option><option value="adjustment">Adjustment</option>' + 1745 '</select></div>' + 1746 '<div><label style="font-size:11px; color:var(--t3); display:block; margin-bottom:4px; font-weight:600;">Method *</label>' + 1747 '<select id="pay-method" style="width:100%; padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:6px;">' + 1748 '<option value="zelle">Zelle</option><option value="ach">ACH</option><option value="account_transfer">Account Transfer</option><option value="check">Check</option><option value="cash">Cash</option><option value="wire">Wire</option><option value="credit_card">Credit Card</option><option value="other">Other</option>' + 1749 '</select></div>' + 1750 '<div><label style="font-size:11px; color:var(--t3); display:block; margin-bottom:4px; font-weight:600;">From Account</label>' + 1751 '<input type="text" id="pay-from" placeholder="Frost 9292" value="Frost 9292" style="width:100%; padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:6px;"></div>' + 1752 '<div style="grid-column:1/-1;"><label style="font-size:11px; color:var(--t3); display:block; margin-bottom:4px; font-weight:600;">WO References (optional)</label>' + 1753 '<input type="text" id="pay-wo" placeholder="JH-100311, JH-100358" style="width:100%; padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:6px;"></div>' + 1754 '<div style="grid-column:1/-1;"><label style="font-size:11px; color:var(--t3); display:block; margin-bottom:4px; font-weight:600;">Notes</label>' + 1755 '<textarea id="pay-notes" rows="2" placeholder="Any context for the audit trail" style="width:100%; padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:6px; resize:vertical; font-family:inherit;"></textarea></div>' + 1756 '</div>' + 1757 '<div style="display:flex; gap:10px; justify-content:flex-end; margin-top:16px;">' + 1758 '<button onclick="document.getElementById(\'fin-payroll-modal\').remove()" style="padding:8px 16px; background:transparent; color:var(--t3); border:1px solid var(--b); border-radius:6px; cursor:pointer;">Cancel</button>' + 1759 '<button onclick="savePayrollPayment()" style="padding:8px 16px; background:var(--gold); color:#fff; border:none; border-radius:6px; cursor:pointer; font-weight:600;">Save Payment</button>' + 1760 '</div></div>'; 1761 document.body.appendChild(modal); 1762 }; 1763 1764 window.savePayrollPayment = function() { 1765 var rep = document.getElementById('pay-rep').value; 1766 var date = document.getElementById('pay-date').value; 1767 var amt = parseFloat(document.getElementById('pay-amount').value); 1768 var type = document.getElementById('pay-type').value; 1769 var method = document.getElementById('pay-method').value; 1770 var fromAcct = document.getElementById('pay-from').value; 1771 var wo = (document.getElementById('pay-wo').value || '').trim(); 1772 var notes = (document.getElementById('pay-notes').value || '').trim(); 1773 if (!rep) { alert('Please select a rep.'); return; } 1774 if (!date) { alert('Please pick a date.'); return; } 1775 if (isNaN(amt) || amt <= 0) { alert('Please enter a valid amount.'); return; } 1776 1777 sb.from('rep_payments').insert([{ 1778 rep_name: rep, payment_date: date, amount: amt, payment_type: type, method: method, 1779 from_account: fromAcct || null, wo_references: wo || null, notes: notes || null, 1780 }]).then(function(result) { 1781 if (result.error) { alert('Error: ' + result.error.message); return; } 1782 var modal = document.getElementById('fin-payroll-modal'); 1783 if (modal) modal.remove(); 1784 window.loadPayrollSection(); 1785 }).catch(function(err) { alert('Error: ' + (err.message || err)); }); 1786 }; 1787 1788 // ===== Installer Payments ===== 1789 window.loadInstallerPayments = function() { 1790 var contentEl = document.getElementById('fin-installer-content'); 1791 contentEl.innerHTML = 'Loading...'; 1792 1793 sb.from('installer_payments').select('*').order('created_at', { ascending: false }).then(function(result) { 1794 var records = result.data || []; 1795 1796 var html = '<table style="width:100%; border-collapse:collapse;">'; 1797 html += '<tr style="background:var(--d3); border-bottom:1px solid var(--b);">'; 1798 html += '<th style="padding:10px; text-align:left; color:var(--t);">Installer</th>'; 1799 html += '<th style="padding:10px; text-align:right; color:var(--t);">Sqft</th>'; 1800 html += '<th style="padding:10px; text-align:right; color:var(--t);">Rate/Sqft</th>'; 1801 html += '<th style="padding:10px; text-align:right; color:var(--t);">Total Pay</th>'; 1802 html += '<th style="padding:10px; text-align:center; color:var(--t);">Status</th>'; 1803 html += '<th style="padding:10px; text-align:center; color:var(--t);">Actions</th>'; 1804 html += '</tr>'; 1805 1806 records.forEach(function(row) { 1807 html += '<tr style="border-bottom:1px solid var(--b);">'; 1808 html += '<td style="padding:10px; color:var(--t);">
1808' + row.installer_name + '</td>'; 1809 html += '<td style="padding:10px; text-align:right; color:var(--t);">' + parseFloat(row.sqft).toFixed(0) + '</td>'; 1810 html += '<td style="padding:10px; text-align:right; color:var(--t);">' + '$' + fmtMoney(row.rate_per_sqft) + '</td>'; 1811 html += '<td style="padding:10px; text-align:right; color:var(--gold);">' + '$' + fmtMoney(row.total_pay) + '</td>'; 1812 html += '<td style="padding:10px; text-align:center; color:var(--t);">' + row.status + '</td>'; 1813 html += '<td style="padding:10px; text-align:center;">'; 1814 html += '<button onclick="deleteFinRecord(\'installer_payments\', ' + row.id + ')" style="padding:4px 8px; background:#c33; color:#fff; border:none; cursor:pointer; border-radius:3px; font-size:12px;">Delete</button>'; 1815 html += '</td>'; 1816 html += '</tr>'; 1817 }); 1818 1819 html += '</table>'; 1820 contentEl.innerHTML = html; 1821 }).catch(function(err) { 1822 console.error('Installer payments error:', err); 1823 contentEl.innerHTML = '<div style="color:var(--t2);">Error loading data</div>'; 1824 }); 1825 }; 1826 1827 // ===== Vendor Payments ===== 1828 window.loadVendorPayments = function() { 1829 var contentEl = document.getElementById('fin-vendor-content'); 1830 contentEl.innerHTML = 'Loading...'; 1831 1832 sb.from('vendor_payment_tracking').select('*').order('created_at', { ascending: false }).then(function(result) { 1833 var records = result.data || []; 1834 1835 var html = '<table style="width:100%; border-collapse:collapse;">'; 1836 html += '<tr style="background:var(--d3); border-bottom:1px solid var(--b);">'; 1837 html += '<th style="padding:10px; text-align:left; color:var(--t);">Vendor</th>'; 1838 html += '<th style="padding:10px; text-align:right; color:var(--t);">Amount</th>'; 1839 html += '<th style="padding:10px; text-align:left; color:var(--t);">Invoice #</th>'; 1840 html += '<th style="padding:10px; text-align:center; color:var(--t);">Date</th>'; 1841 html += '<th style="padding:10px; text-align:center; color:var(--t);">Status</th>'; 1842 html += '<th style="padding:10px; text-align:left; color:var(--t);">Notes</th>'; 1843 html += '<th style="padding:10px; text-align:center; color:var(--t);">Actions</th>'; 1844 html += '</tr>'; 1845 1846 records.forEach(function(row) { 1847 html += '<tr style="border-bottom:1px solid var(--b);">'; 1848 html += '<td style="padding:10px; color:var(--t);">' + row.vendor_name + '</td>'; 1849 html += '<td style="padding:10px; text-align:right; color:var(--gold);">$' + fmtMoney(row.amount) + '</td>'; 1850 html += '<td style="padding:10px; color:var(--t2);">' + (row.invoice_number || '-') + '</td>'; 1851 html += '<td style="padding:10px; text-align:center; color:var(--t2);">' + fmtDate(row.payment_date) + '</td>'; 1852 html += '<td style="padding:10px; text-align:center; color:var(--t);">' + row.status + '</td>'; 1853 html += '<td style="padding:10px; color:var(--t2); font-size:12px;">' + (row.notes || '-') + '</td>'; 1854 html += '<td style="padding:10px; text-align:center;">'; 1855 html += '<button onclick="deleteFinRecord(\'vendor_payment_tracking\', ' + row.id + ')" style="padding:4px 8px; background:#c33; color:#fff; border:none; cursor:pointer; border-radius:3px; font-size:12px;">Delete</button>'; 1856 html += '</td>'; 1857 html += '</tr>'; 1858 }); 1859 1860 html += '</table>'; 1861 contentEl.innerHTML = html; 1862 }).catch(function(err) { 1863 console.error('Vendor payments error:', err); 1864 contentEl.innerHTML = '<div style="color:var(--t2);">Error loading data</div>'; 1865 }); 1866 }; 1867 1868 // ===== Deductions ===== 1869 // Filter state, persisted across re-renders 1870 window._finDeductionsFilter = window._finDeductionsFilter || { rep: 'all', status: 'all' }; 1871 1872 window.loadDeductions = function() { 1873 var contentEl = document.getElementById('fin-deductions-content'); 1874 contentEl.innerHTML = 'Loading...'; 1875 1876 sb.from('rep_deductions').select('*').order('created_at', { ascending: false }).then(function(result) { 1877 var allRecords = result.data || []; 1878 var f = window._finDeductionsFilter; 1879 1880 // Distinct reps for the filter dropdown 1881 var repSet = {}; 1882 allRecords.forEach(function(r) { if (r.rep_name) repSet[r.rep_name] = true; }); 1883 var reps = Object.keys(repSet).sort(); 1884 1885 // Apply filters 1886 var records = allRecords.filter(function(r) { 1887 if (f.rep !== 'all' && r.rep_name !== f.rep) return false;
1888 if (f.status !== 'all' && (r.status || 'new') !== f.status) return false; 1889 return true; 1890 }); 1891 1892 // Per-rep totals strip (always uses ALL records, not filtered, so user sees the full picture) 1893 var perRep = {}; 1894 allRecords.forEach(function(r) { 1895 var n = r.rep_name || 'Unknown'; 1896 if (!perRep[n]) perRep[n] = { total: 0, paid: 0, pending: 0, partial: 0 }; 1897 var amt = parseFloat(r.amount || 0); 1898 var paidAmt = parseFloat(r.paid_amount || 0); 1899 var st = r.status || 'new'; 1900 perRep[n].total += amt; 1901 if (st === 'paid') perRep[n].paid += amt; 1902 else if (st === 'partial') { 1903 perRep[n].partial += amt; 1904 perRep[n].paid += paidAmt; 1905 perRep[n].pending += (amt - paidAmt); 1906 } 1907 else perRep[n].pending += amt; 1908 }); 1909 1910 var html = ''; 1911 1912 // Filter bar 1913 html += '<div style="display:flex; gap:10px; align-items:center; margin-bottom:14px; flex-wrap:wrap;">'; 1914 html += '<label style="font-size:11px; color:var(--t3); text-transform:uppercase; font-weight:700;">Rep:</label>'; 1915 html += '<select id="fin-ded-rep-filter" onchange="setFinDeductionsFilter(\'rep\', this.value)" style="padding:6px 12px; background:var(--d3); border:1px solid var(--b); color:var(--t); border-radius:6px; font-size:12px;">'; 1916 html += '<option value="all"' + (f.rep==='all'?' selected':'') + '>All Reps</option>'; 1917 reps.forEach(function(rn) { 1918 html += '<option value="' + rn.replace(/"/g,'"') + '"' + (f.rep===rn?' selected':'') + '>' + rn + '</option>'; 1919 }); 1920 html += '</select>'; 1921 html += '<label style="font-size:11px; color:var(--t3); text-transform:uppercase; font-weight:700; margin-left:6px;">Status:</label>'; 1922 html += '<select id="fin-ded-status-filter" onchange="setFinDeductionsFilter(\'status\', this.value)" style="padding:6px 12px; background:var(--d3); border:1px solid var(--b); color:var(--t); border-radius:6px; font-size:12px;">'; 1923 html += '<option value="all"' + (f.status==='all'?' selected':'') + '>All Statuses</option>'; 1924 html += '<option value="new"' + (f.status==='new'?' selected':'') + '>NEW (not yet deducted)</option>'; 1925 html += '<option value="partial"' + (f.status==='partial'?' selected':'') + '>PARTIAL (partially deducted)</option>'; 1926 html += '<option value="paid"' + (f.status==='paid'?' selected':'') + '>PAID (fully deducted)</option>'; 1927 html += '</select>'; 1928 html += '<div style="flex:1"></div>'; 1929 html += '<div style="font-size:11px; color:var(--t3);">' + records.length + ' of ' + allRecords.length + ' rows</div>'; 1930 html += '</div>'; 1931 1932 // Per-rep summary cards 1933 html += '<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:10px; margin-bottom:18px;">'; 1934 Object.keys(perRep).sort().forEach(function(rn) { 1935 var d = perRep[rn]; 1936 html += '<div style="background:var(--d3); border:1px solid var(--b); border-radius:8px; padding:12px;">'; 1937 html += '<div style="font-size:11px; color:var(--t3); text-transform:uppercase; font-weight:700; margin-bottom:4px;">' + rn + '</div>'; 1938 html += '<div style="font-size:18px; font-weight:800; color:var(--t);">$' + fmtMoney(d.total) + ' total</div>'; 1939 html += '<div style="font-size:11px; color:#16a34a; margin-top:3px;">â Deducted: $' + fmtMoney(d.paid) + '</div>'; 1940 html += '<div style="font-size:11px; color:#f59e0b;">â³ Pending: $' + fmtMoney(d.pending) + '</div>'; 1941 html += '</div>'; 1942 }); 1943 html += '</div>'; 1944 1945 // Table 1946 html += '<table style="width:100%; border-collapse:collapse;">'; 1947 html += '<tr style="background:var(--d3); border-bottom:1px solid var(--b);">'; 1948 html += '<th style="padding:10px; text-align:left; color:var(--t3); font-size:11px; text-transform:uppercase;">Status</th>'; 1949 html += '<th style="padding:10px; text-align:left; color:var(--t3); font-size:11px; text-transform:uppercase;">Rep Name</th>'; 1950 html += '<th style="padding:10px; text-align:right; color:var(--t3); font-size:11px; text-transform:uppercase;">Amount</th>'; 1951 html += '<th style="padding:10px; text-align:right; color:var(--t3); font-size:11px; text-transform:uppercase;">Deducted</th>'; 1952 html += '<th style="padding:10px; text-align:left; color:var(--t3); font-size:11px; text-transform:uppercase;">Reason</th>'; 1953 html += '<th style="padding:10px; text-align:center; color:var(--t3); font-size:11px; text-transform:uppercase;">Date</th>'; 1954 html += '<th style="padding:10px; text-align:left; color:var(--t3); font-size:11px; text-transform:uppercase;">Job Ref</th>'; 1955 html += '<th style="padding:10px; text-align:center; color:var(--t3); font-size:11px; text-transform:uppercase;">Actions</th>'; 1956 html += '</tr>'; 1957 1958 if (records.length === 0) { 1959 html += '<tr><td colspan="8" style="padding:30px; text-align:center; color:var(--t3);">No rows match the current filter.</td></tr>'; 1960 } 1961 1962 records.forEach(function(row) { 1963 var st = row.status || 'new'; 1964 var paidAmt = parseFloat(row.paid_amount || 0); 1965 var amt = parseFloat(row.amount || 0); 1966 1967 // Status badge styling 1968 var badgeStyle, badgeLabel; 1969 if (st === 'paid') { 1970 badgeStyle = 'background:#16a34a22; color:#16a34a;'; badgeLabel = 'â PAID'; 1971 } else if (st === 'partial') { 1972 badgeStyle = 'background:#f59e0b22; color:#f59e0b;'; badgeLabel = 'â PARTIAL'; 1973 } else { 1974 badgeStyle = 'background:#ef444422;
1974 color:#ef4444;'; badgeLabel = 'â³ NEW'; 1975 } 1976 1977 // Row left-border based on status (visual cue) 1978 var rowBorder = st === 'paid' ? '' : (st === 'partial' ? 'border-left:3px solid #f59e0b;' : 'border-left:3px solid #ef4444;'); 1979 1980 html += '<tr style="border-bottom:1px solid var(--b);' + rowBorder + '">'; 1981 html += '<td style="padding:10px;"><span style="display:inline-block; padding:3px 9px; border-radius:10px; font-size:10px; font-weight:700; letter-spacing:0.5px; ' + badgeStyle + '">' + badgeLabel + '</span></td>'; 1982 html += '<td style="padding:10px; color:var(--t); font-weight:600;">' + (row.rep_name||'') + '</td>'; 1983 html += '<td style="padding:10px; text-align:right; color:#c33; font-weight:600;">-$' + fmtMoney(amt) + '</td>'; 1984 html += '<td style="padding:10px; text-align:right; color:#16a34a;">' + (paidAmt > 0 ? '$' + fmtMoney(paidAmt) : 'â') + '</td>'; 1985 html += '<td style="padding:10px; color:var(--t2); font-size:12px;">' + (row.reason||'') + '</td>'; 1986 html += '<td style="padding:10px; text-align:center; color:var(--t2); font-size:12px;">' + fmtDate(row.deduction_date) + '</td>'; 1987 html += '<td style="padding:10px; color:var(--t2); font-size:12px;">' + (row.job_reference || '-') + '</td>'; 1988 html += '<td style="padding:10px; text-align:center; white-space:nowrap;">'; 1989 if (st !== 'paid') { 1990 html += '<button onclick="markDeductionStatus(' + row.id + ', \'paid\', ' + amt + ')" style="padding:4px 8px; background:#16a34a; color:#fff; border:none; cursor:pointer; border-radius:3px; font-size:11px; font-weight:600; margin-right:3px;">â Mark Paid</button>'; 1991 } 1992 if (st === 'new') { 1993 html += '<button onclick="markDeductionStatus(' + row.id + ', \'partial\')" style="padding:4px 8px; background:#f59e0b; color:#fff; border:none; cursor:pointer; border-radius:3px; font-size:11px; font-weight:600; margin-right:3px;">â Partialâ¦</button>'; 1994 } 1995 if (st !== 'new') { 1996 html += '<button onclick="markDeductionStatus(' + row.id + ', \'new\')" title="Reset to NEW (mark as not yet deducted)" style="padding:4px 8px; background:transparent; color:var(--t3); border:1px solid var(--b); cursor:pointer; border-radius:3px; font-size:11px; margin-right:3px;">â¶ Undo</button>'; 1997 } 1998 html += '<button onclick="deleteFinRecord(\'rep_deductions\', ' + row.id + ')" style="padding:4px 8px; background:#c33; color:#fff; border:none; cursor:pointer; border-radius:3px; font-size:11px;">Delete</button>'; 1999 html += '</td>'; 2000 html += '</tr>'; 2001 }); 2002 2003 html += '</table>'; 2004 html += '<div style="margin-top:14px; padding:10px 14px; background:var(--d3); border-left:3px solid var(--gold); border-radius:6px; font-size:12px; color:var(--t3); line-height:1.6;">'; 2005 html += '<strong style="color:var(--gold);">How statuses work:</strong> '; 2006 html += '<span style="color:#ef4444;">â³ NEW</span> = not yet deducted from the rep\'s commission · '; 2007 html += '<span style="color:#f59e0b;">â PARTIAL</span> = partially deducted (enter the deducted amount when marking) · '; 2008 html += '<span style="color:#16a34a;">â PAID</span> = fully deducted. '; 2009 html += 'The Commission Dashboard\'s Net Pay only subtracts amounts that are actually marked deducted, so NEW deductions show as informational without double-counting.'; 2010 html += '</div>'; 2011 2012 contentEl.innerHTML = html; 2013 }).catch(function(err) { 2014 console.error('Deductions error:', err); 2015 contentEl.innerHTML = '<div style="color:var(--t2);">Error loading data: ' + (err.message || err) + '</div>'; 2016 }); 2017 }; 2018 2019 // Filter setter 2020 window.setFinDeductionsFilter = function(field, value) { 2021 window._finDeductionsFilter[field] = value; 2022 window.loadDeductions(); 2023 }; 2024 2025 // Mark status (paid / partial / new) 2026 window.markDeductionStatus = function(id, newStatus, fullAmount) { 2027 var update = { status: newStatus }; 2028 2029 if (newStatus === 'paid') { 2030 update.paid_amount = fullAmount; 2031 update.paid_date = new Date().toISOString().split('T')[0]; 2032 if (!confirm('Mark this deduction as PAID (fully deducted from rep commission)? Net Pay on the Commission Dashboard will reflect this.')) return; 2033 } else if (newStatus === 'partial') { 2034 var raw = prompt('Enter the amount that has been deducted so far (partial):', '0'); 2035 if (raw === null) return; 2036 var partialAmt = parseFloat(raw); 2037 if (isNaN(partialAmt) || partialAmt < 0) { alert('Invalid amount'); return; } 2038 update.paid_amount = partialAmt; 2039 update.paid_date = new Date().toISOString().split('T')[0]; 2040 } else if (newStatus === 'new') { 2041 update.paid_amount = 0; 2042 update.paid_date = null; 2043 if (!confirm('Reset this deduction to NEW (mark as NOT YET deducted)? Net Pay on the Commission Dashboard will add it back to the rep\'s pending balance.')) return; 2044 } 2045 2046 sb.from('rep_deductions').update(update).eq('id', id).then(function(result) { 2047 if (result.error) { alert('Error updating status: ' + result.error.message); return; } 2048 window.loadDeductions(); 2049 }).catch(function(err) { 2050 alert('Error: ' + (err.message || err)); 2051 }); 2052 }; 2053 2054 // ===== Store Credits =====
2055 window.loadStoreCredits = function() { 2056 var contentEl = document.getElementById('fin-credits-content'); 2057 contentEl.innerHTML = 'Loading...'; 2058 2059 sb.from('store_credits').select('*').order('created_at', { ascending: false }).then(function(result) { 2060 var records = result.data || []; 2061 2062 var html = '<table style="width:100%; border-collapse:collapse;">'; 2063 html += '<tr style="background:var(--d3); border-bottom:1px solid var(--b);">'; 2064 html += '<th style="padding:10px; text-align:left; color:var(--t);">Entity</th>'; 2065 html += '<th style="padding:10px; text-align:left; color:var(--t);">Type</th>'; 2066 html += '<th style="padding:10px; text-align:right; color:var(--t);">Amount</th>'; 2067 html += '<th style="padding:10px; text-align:left; color:var(--t);">Reason</th>'; 2068 html += '<th style="padding:10px; text-align:center; color:var(--t);">Status</th>'; 2069 html += '<th style="padding:10px; text-align:center; color:var(--t);">Actions</th>'; 2070 html += '</tr>'; 2071 2072 records.forEach(function(row) { 2073 html += '<tr style="border-bottom:1px solid var(--b);">'; 2074 html += '<td style="padding:10px; color:var(--t);">' + row.entity_name + '</td>'; 2075 html += '<td style="padding:10px; color:var(--t2);">' + row.entity_type + '</td>'; 2076 html += '<td style="padding:10px; text-align:right; color:var(--gold);">$' + fmtMoney(row.amount) + '</td>'; 2077 html += '<td style="padding:10px; color:var(--t2);">' + row.reason + '</td>'; 2078 html += '<td style="padding:10px; text-align:center; color:var(--t);">' + row.status + '</td>'; 2079 html += '<td style="padding:10px; text-align:center;">'; 2080 html += '<button onclick="deleteFinRecord(\'store_credits\', ' + row.id + ')" style="padding:4px 8px; background:#c33; color:#fff; border:none; cursor:pointer; border-radius:3px; font-size:12px;">Delete</button>'; 2081 html += '</td>'; 2082 html += '</tr>'; 2083 }); 2084 2085 html += '</table>'; 2086 contentEl.innerHTML = html; 2087 }).catch(function(err) { 2088 console.error('Store credits error:', err); 2089 contentEl.innerHTML = '<div style="color:var(--t2);">Error loading data</div>'; 2090 }); 2091 }; 2092 2093 // ===== Client Refunds ===== 2094 window.loadClientRefunds = function() { 2095 var contentEl = document.getElementById('fin-refunds-content'); 2096 contentEl.innerHTML = 'Loading...'; 2097 2098 sb.from('client_refunds').select('*').order('created_at', { ascending: false }).then(function(result) { 2099 var records = result.data || []; 2100 2101 var html = '<table style="width:100%; border-collapse:collapse;">'; 2102 html += '<tr style="background:var(--d3); border-bottom:1px solid var(--b);">'; 2103 html += '<th style="padding:10px; text-align:left; color:var(--t);">Client</th>'; 2104 html += '<th style="padding:10px; text-align:right; color:var(--t);">Amount</th>'; 2105 html += '<th style="padding:10px; text-align:left; color:var(--t);">Reason</th>'; 2106 html += '<th style="padding:10px; text-align:left; color:var(--t);">Invoice #</th>'; 2107 html += '<th style="padding:10px; text-align:center; color:var(--t);">Date</th>'; 2108 html += '<th style="padding:10px; text-align:center; color:var(--t);">Status</th>'; 2109 html += '<th style="padding:10px; text-align:center; color:var(--t);">Actions</th>'; 2110 html += '</tr>'; 2111 2112 records.forEach(function(row) { 2113 html += '<tr style="border-bottom:1px solid var(--b);">'; 2114 html += '<td style="padding:10px; color:var(--t);">' + row.client_name + '</td>'; 2115 html += '<td style="padding:10px; text-align:right; color:#c33;">-$' + fmtMoney(row.amount) + '</td>'; 2116 html += '<td style="padding:10px; color:var(--t2);">' + row.reason + '</td>'; 2117 html += '<td style="padding:10px; color:var(--t2);">' + (row.invoice_id || '-') + '</td>'; 2118 html += '<td style="padding:10px; text-align:center; color:var(--t2);">' + fmtDate(row.refund_date) + '</td>'; 2119 html += '<td style="padding:10px; text-align:center; color:var(--t);">' + row.status + '</td>'; 2120 html += '<td style="padding:10px; text-align:center;">'; 2121 html += '<button onclick="deleteFinRecord(\'client_refunds\', ' + row.id + ')" style="padding:4px 8px; background:#c33; color:#fff; border:none; cursor:pointer; border-radius:3px; font-size:12px;">Delete</button>'; 2122 html += '</td>'; 2123 html += '</tr>'; 2124 }); 2125 2126 html += '</table>'; 2127 contentEl.innerHTML = html; 2128 }).catch(function(err) { 2129 console.error('Client refunds error:', err); 2130 contentEl.innerHTML = '<div style="color:var(--t2);">Error loading data</div>'; 2131 }); 2132 }; 2133 2134 // ===== Storage Fees ===== 2135 window.loadStorageFees = function() { 2136 var contentEl = document.getElementById('fin-storage-content'); 2137 contentEl.innerHTML = 'Loading...'; 2138 2139 sb.from('storage_fees').select('*').order('created_at', { ascending: false }).then(function(result) { 2140 var records = result.data || []; 2141 2142 var html = '<table style="width:100%; border-collapse:collapse;">'; 2143 html += '<tr style="background:var(--d3); border-bottom:1px solid var(--b);">'; 2144 html += '<th style="padding:10px; text-align:left; color:var(--t);">Client</th>'; 2145 html += '<th style="padding:10px; text-align:right; color:var(--t);">Monthly Rate</th>'; 2146 html += '<th style="padding:10px; text-align:center; color:var(--t);">Start Date</th>'; 2147 html += '<th style="padding:10px; text-align:center; color:var(--t);">End Date</th>'; 2148 html += '<th style="padding:10px; text-align:right; color:var(--t);">Total Charged</th>'; 2149 html += '<th style="padding:10px; text-align:center; color:var(--t);">Status</th>'; 2150 html += '<th style="padding:10px; text-align:center; color:var(--t);">Actions</th>'; 2151 html += '</tr>'; 2152 2153 records.forEach(function(row) { 2154 html += '<tr style="border-bottom:1px solid var(--b);">'; 2155 html += '<td style="padding:10px; color:var(--t);">
2155' + row.client_name + '</td>'; 2156 html += '<td style="padding:10px; text-align:right; color:var(--t);">' + '$' + fmtMoney(row.monthly_rate) + '</td>'; 2157 html += '<td style="padding:10px; text-align:center; color:var(--t2);">' + fmtDate(row.start_date) + '</td>'; 2158 html += '<td style="padding:10px; text-align:center; color:var(--t2);">' + (row.end_date ? fmtDate(row.end_date) : '-') + '</td>'; 2159 html += '<td style="padding:10px; text-align:right; color:var(--gold);">' + '$' + fmtMoney(row.total_charged) + '</td>'; 2160 html += '<td style="padding:10px; text-align:center; color:var(--t);">' + row.status + '</td>'; 2161 html += '<td style="padding:10px; text-align:center;">'; 2162 html += '<button onclick="deleteFinRecord(\'storage_fees\', ' + row.id + ')" style="padding:4px 8px; background:#c33; color:#fff; border:none; cursor:pointer; border-radius:3px; font-size:12px;">Delete</button>'; 2163 html += '</td>'; 2164 html += '</tr>'; 2165 }); 2166 2167 html += '</table>'; 2168 contentEl.innerHTML = html; 2169 }).catch(function(err) { 2170 console.error('Storage fees error:', err); 2171 contentEl.innerHTML = '<div style="color:var(--t2);">Error loading data</div>'; 2172 }); 2173 }; 2174 2175 // ===== Office Expenses ===== 2176 window.loadOfficeExpenses = function() { 2177 var contentEl = document.getElementById('fin-expenses-content'); 2178 contentEl.innerHTML = 'Loading...'; 2179 2180 sb.from('office_expenses').select('*').order('created_at', { ascending: false }).then(function(result) { 2181 var records = result.data || []; 2182 2183 var html = '<table style="width:100%; border-collapse:collapse;">'; 2184 html += '<tr style="background:var(--d3); border-bottom:1px solid var(--b);">'; 2185 html += '<th style="padding:10px; text-align:left; color:var(--t);">Category</th>'; 2186 html += '<th style="padding:10px; text-align:left; color:var(--t);">Description</th>'; 2187 html += '<th style="padding:10px; text-align:right; color:var(--t);">Amount</th>'; 2188 html += '<th style="padding:10px; text-align:center; color:var(--t);">Date</th>'; 2189 html += '<th style="padding:10px; text-align:left; color:var(--t);">Vendor</th>'; 2190 html += '<th style="padding:10px; text-align:center; color:var(--t);">Recurring</th>'; 2191 html += '<th style="padding:10px; text-align:center; color:var(--t);">Actions</th>'; 2192 html += '</tr>'; 2193 2194 records.forEach(function(row) { 2195 html += '<tr style="border-bottom:1px solid var(--b);">'; 2196 html += '<td style="padding:10px; color:var(--t);">' + row.category + '</td>'; 2197 html += '<td style="padding:10px; color:var(--t2);">' + row.description + '</td>'; 2198 html += '<td style="padding:10px; text-align:right; color:#c33;">-$' + fmtMoney(row.amount) + '</td>'; 2199 html += '<td style="padding:10px; text-align:center; color:var(--t2);">' + fmtDate(row.expense_date) + '</td>'; 2200 html += '<td style="padding:10px; color:var(--t2);">' + (row.vendor || '-') + '</td>'; 2201 html += '<td style="padding:10px; text-align:center; color:var(--t2);">' + (row.recurring ? 'Yes' : 'No') + '</td>'; 2202 html += '<td style="padding:10px; text-align:center;">'; 2203 html += '<button onclick="deleteFinRecord(\'office_expenses\', ' + row.id + ')" style="padding:4px 8px; background:#c33; color:#fff; border:none; cursor:pointer; border-radius:3px; font-size:12px;">Delete</button>'; 2204 html += '</td>'; 2205 html += '</tr>'; 2206 }); 2207 2208 html += '</table>'; 2209 contentEl.innerHTML = html; 2210 }).catch(function(err) { 2211 console.error('Office expenses error:', err); 2212 contentEl.innerHTML = '<div style="color:var(--t2);">Error loading data</div>'; 2213 }); 2214 }; 2215 2216 // ===== Form Toggle ===== 2217 window.toggleFinForm = function(formType) { 2218 var formEl = document.getElementById('fin-' + formType.split('-')[0] + '-form'); 2219 if (!formEl) return; 2220 2221 if (formEl.style.display === 'none' || !formEl.style.display) { 2222 formEl.style.display = 'block'; 2223 buildFinForm(formType, formEl); 2224 } else { 2225 formEl.style.display = 'none'; 2226 } 2227 }; 2228 2229 // ===== Generic Form Builder ===== 2230 window.buildFinForm = function(formType, containerEl) { 2231 var html = ''; 2232 var baseType = formType.split('-')[0]; 2233 2234 if (baseType === 'installer') { 2235 html = ` 2236 <div style="display:grid; grid-template-columns:1fr 1fr; gap:15px;"> 2237 <input id="fin-installer-name" type="text" placeholder="Installer Name" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2238 <input id="fin-installer-wo" type="text" placeholder="Work Order ID" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2239 <input id="fin-installer-sqft" type="number" placeholder="Sqft" step="0.01" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" />
2240 <input id="fin-installer-rate" type="number" placeholder="Rate per Sqft" step="0.01" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2241 <select id="fin-installer-status" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;"> 2242 <option value="pending">Pending</option> 2243 <option value="paid">Paid</option> 2244 </select> 2245 <input id="fin-installer-date" type="date" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2246 </div> 2247 <button onclick="saveFinRecord('installer_payments')" style="margin-top:10px; padding:8px 16px; background:var(--gold); color:#fff; border:none; cursor:pointer; border-radius:4px;">Save</button> 2248 `; 2249 } else if (baseType === 'vendor') { 2250 html = ` 2251 <div style="display:grid; grid-template-columns:1fr 1fr; gap:15px;"> 2252 <input id="fin-vendor-name" type="text" placeholder="Vendor Name" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2253 <input id="fin-vendor-amount" type="number" placeholder="Amount" step="0.01" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2254 <input id="fin-vendor-invoice" type="text" placeholder="Invoice Number" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2255 <input id="fin-vendor-date" type="date" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2256 <select id="fin-vendor-status" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;"> 2257 <option value="pending">Pending</option> 2258 <option value="paid">Paid</option> 2259 </select> 2260 <input id="fin-vendor-notes" type="text" placeholder="Notes" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2261 </div> 2262 <button onclick="saveFinRecord('vendor_payment_tracking')" style="margin-top:10px; padding:8px 16px; background:var(--gold); color:#fff; border:none; cursor:pointer; border-radius:4px;">Save</button> 2263 `; 2264 } else if (baseType === 'deductions') { 2265 // Build the rep <select> options from window.profs (active reps). 2266 // Falls back to a hardcoded list if profs isn't loaded yet. 2267 var _activeReps = (typeof profs !== 'undefined' && Array.isArray(profs)) 2268 ? profs.filter(function(p){ return p && p.role !== 'inactive' && p.full_name; }).slice().sort(function(a,b){ return (a.full_name||'').localeCompare(b.full_name||''); }) 2269 : [ 2270 { full_name: 'Ashley Brooke' }, 2271 { full_name: 'Patrick McNeill' }, 2272 { full_name: 'Samin Seifpour' }, 2273 { full_name: 'Aly Champagne' }, 2274 { full_name: 'Joey Jamail' } 2275 ]; 2276 var _repOptions = '<option value="">â Select sales rep â</option>' + 2277 _activeReps.map(function(p){ 2278 var name = (typeof esc === 'function') ? esc(p.full_name) : p.full_name; 2279 return '<option value="' + name + '">' + name + '</option>'; 2280 }).join(''); 2281 html = ` 2282 <div style="display:grid; grid-template-columns:1fr 1fr; gap:15px;"> 2283 <select id="fin-deductions-name" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;">${_repOptions}</select> 2284 <input id="fin-deductions-amount" type="number" placeholder="Amount" step="0.01" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2285 <input id="fin-deductions-reason" type="text" placeholder="Reason" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2286 <input id="fin-deductions-date" type="date" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2287 <input id="fin-deductions-jobref" type="text" placeholder="Job Reference (optional â e.g., INV-2578)" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2288 <select id="fin-deductions-status" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;"> 2289 <option value="new" selected>NEW â not yet deducted</option> 2290 <option value="partial">PARTIAL â partially deducted</option> 2291 <option value="paid">PAID â fully deducted</option> 2292 </select> 2293 </div> 2294 <button onclick="saveFinRecord('rep_deductions')" style="margin-top:10px; padding:8px 16px; background:var(--gold); color:#fff; border:none; cursor:pointer; border-radius:4px;">Save</button> 2295 `; 2296 } else if (baseType === 'credits') { 2297 html = ` 2298 <div style="display:grid; grid-template-columns:1fr 1fr; gap:15px;"> 2299 <select id="fin-credits-type" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;"> 2300 <option value="client">Client</option> 2301 <option value="vendor">Vendor</option> 2302 </select> 2303 <input id="fin-credits-entity" type="text" placeholder="Client/Ven
2303dor Name" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2304 <input id="fin-credits-amount" type="number" placeholder="Amount" step="0.01" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2305 <input id="fin-credits-reason" type="text" placeholder="Reason" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2306 <input id="fin-credits-date" type="date" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2307 <select id="fin-credits-status" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;"> 2308 <option value="active">Active</option> 2309 <option value="used">Used</option> 2310 <option value="expired">Expired</option> 2311 </select> 2312 </div> 2313 <button onclick="saveFinRecord('store_credits')" style="margin-top:10px; padding:8px 16px; background:var(--gold); color:#fff; border:none; cursor:pointer; border-radius:4px;">Save</button> 2314 `; 2315 } else if (baseType === 'refunds') { 2316 html = ` 2317 <div style="display:grid; grid-template-columns:1fr 1fr; gap:15px;"> 2318 <input id="fin-refunds-client" type="text" placeholder="Client Name" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2319 <input id="fin-refunds-amount" type="number" placeholder="Amount" step="0.01" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2320 <input id="fin-refunds-reason" type="text" placeholder="Reason" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2321 <input id="fin-refunds-invoice" type="text" placeholder="Invoice ID" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2322 <input id="fin-refunds-date" type="date" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2323 <select id="fin-refunds-status" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;"> 2324 <option value="pending">Pending</option> 2325 <option value="approved">Approved</option> 2326 <option value="completed">Completed</option> 2327 </select> 2328 </div> 2329 <button onclick="saveFinRecord('client_refunds')" style="margin-top:10px; padding:8px 16px; background:var(--gold); color:#fff; border:none; cursor:pointer; border-radius:4px;">Save</button> 2330 `; 2331 } else if (baseType === 'storage') { 2332 html = ` 2333 <div style="display:grid; grid-template-columns:1fr 1fr; gap:15px;"> 2334 <input id="fin-storage-client" type="text" placeholder="Client Name" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2335 <input id="fin-storage-rate" type="number" placeholder="Monthly Rate" step="0.01" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2336 <input id="fin-storage-start" type="date" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2337 <input id="fin-storage-end" type="date" placeholder="(optional)" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2338 <input id="fin-storage-total" type="number" placeholder="Total Charged" step="0.01" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2339 <select id="fin-storage-status" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;"> 2340 <option value="active">Active</option> 2341 <option value="inactive">Inactive</option> 2342 </select> 2343 </div> 2344 <button onclick="saveFinRecord('storage_fees')" style="margin-top:10px; padding:8px 16px; background:var(--gold); color:#fff; border:none; cursor:pointer; border-radius:4px;">Save</button> 2345 `; 2346 } else if (baseType === 'expenses') { 2347 html = ` 2348 <div style="display:grid; grid-template-columns:1fr 1fr; gap:15px;"> 2349 <input id="fin-expenses-category" type="text" placeholder="Category (Rent, Utilities, etc)" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2350 <input id="fin-expenses-description" type="text" placeholder="Description" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2351 <input id="fin-expenses-amount" type="number" placeholder="Amount" step="0.01" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2352 <input id="fin-expenses-vendor" type="text" placeholder="Vendor" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2353 <input id="fin-expenses-date" type="date" style="padding:8px; background:var(--d1); border:1px solid var(--b); color:var(--t); border-radius:4px;" /> 2354 <label style="display:flex; align-items:center; gap:8px; color:var(--t);"> 2355 <input id="fin-expenses-recurring" type="checkbox" /> 2356 Recurring 2357 </label> 2358 </div> 2359 <button onclick="saveFinRecord('office_expenses')" style="margin-top:10px; padding:8px 16px; background:var(--gold); color:#fff; border:none; cursor:pointer; border-radius:4px;">Save</button> 2360 `; 2361 } 2362 2363 containerEl.innerHTML = html; 2364 }; 2365 2366 // ===== Save Finance Record ===== 2367 window.saveFinRecord = function(tableName) { 2368 var record = {}; 2369 2370 if (tableName === 'installer_payments') { 2371 record = { 2372 installer_name: document.getElementById('fin-installer-name').value, 2373 work_order_id: document.getElementById('fin-installer-wo').value || null, 2374 sqft: parseFloat(document.getElementById('fin-installer-sqft').value || 0
2374), 2375 rate_per_sqft: parseFloat(document.getElementById('fin-installer-rate').value || 0), 2376 total_pay: parseFloat(document.getElementById('fin-installer-sqft').value || 0) * parseFloat(document.getElementById('fin-installer-rate').value || 0), 2377 status: document.getElementById('fin-installer-status').value, 2378 payment_date: document.getElementById('fin-installer-date').value 2379 }; 2380 } else if (tableName === 'vendor_payment_tracking') { 2381 record = { 2382 vendor_name: document.getElementById('fin-vendor-name').value, 2383 amount: parseFloat(document.getElementById('fin-vendor-amount').value || 0), 2384 invoice_number: document.getElementById('fin-vendor-invoice').value, 2385 payment_date: document.getElementById('fin-vendor-date').value, 2386 status: document.getElementById('fin-vendor-status').value, 2387 notes: document.getElementById('fin-vendor-notes').value 2388 }; 2389 } else if (tableName === 'rep_deductions') { 2390 var _repNameVal = (document.getElementById('fin-deductions-name').value || '').trim(); 2391 if (!_repNameVal) { alert('Please select a sales rep.'); return; } 2392 var _amount = parseFloat(document.getElementById('fin-deductions-amount').value || 0); 2393 var _statusEl = document.getElementById('fin-deductions-status'); 2394 var _status = _statusEl ? _statusEl.value : 'new'; 2395 var _paidAmt = 0, _paidDate = null; 2396 if (_status === 'paid') { 2397 _paidAmt = _amount; 2398 _paidDate = document.getElementById('fin-deductions-date').value || new Date().toISOString().split('T')[0]; 2399 } else if (_status === 'partial') { 2400 var raw = prompt('How much has been deducted so far (partial)?', '0'); 2401 if (raw === null) return; 2402 _paidAmt = parseFloat(raw) || 0; 2403 _paidDate = new Date().toISOString().split('T')[0]; 2404 } 2405 record = { 2406 rep_name: _repNameVal, 2407 amount: _amount, 2408 reason: document.getElementById('fin-deductions-reason').value, 2409 deduction_date: document.getElementById('fin-deductions-date').value || null, 2410 job_reference: (document.getElementById('fin-deductions-jobref').value || '').trim() || null, 2411 status: _status, 2412 paid_amount: _paidAmt, 2413 paid_date: _paidDate, 2414 }; 2415 } else if (tableName === 'store_credits') { 2416 record = { 2417 entity_type: document.getElementById('fin-credits-type').value, 2418 entity_name: document.getElementById('fin-credits-entity').value, 2419 amount: parseFloat(document.getElementById('fin-credits-amount').value || 0), 2420 reason: document.getElementById('fin-credits-reason').value, 2421 issued_date: document.getElementById('fin-credits-date').value, 2422 status: document.getElementById('fin-credits-status').value 2423 }; 2424 } else if (tableName === 'client_refunds') { 2425 record = { 2426 client_name: document.getElementById('fin-refunds-client').value, 2427 amount: parseFloat(document.getElementById('fin-refunds-amount').value || 0), 2428 reason: document.getElementById('fin-refunds-reason').value, 2429 invoice_id: parseInt(document.getElementById('fin-refunds-invoice').value || 0), 2430 refund_date: document.getElementById('fin-refunds-date').value, 2431 status: document.getElementById('fin-refunds-status').value 2432 }; 2433 } else if (tableName === 'storage_fees') { 2434 record = { 2435 client_name: document.getElementById('fin-storage-client').value, 2436 monthly_rate: parseFloat(document.getElementById('fin-storage-rate').value || 0), 2437 start_date: document.getElementById('fin-storage-start').value, 2438 end_date: document.getElementById('fin-storage-end').value || null, 2439 total_charged: parseFloat(document.getElementById('fin-storage-total').value || 0), 2440 status: document.getElementById('fin-storage-status').value 2441 }; 2442 } else if (tableName === 'office_expenses') { 2443 record = { 2444 category: document.getElementById('fin-expenses-category').value, 2445 description: document.getElementById('fin-expenses-description').value, 2446 amount: parseFloat(document.getElementById('fin-expenses-amount').value || 0
2446), 2447 vendor: document.getElementById('fin-expenses-vendor').value, 2448 expense_date: document.getElementById('fin-expenses-date').value, 2449 recurring: document.getElementById('fin-expenses-recurring').checked 2450 }; 2451 } 2452 2453 // Map table names to section names for form hiding and section reload 2454 var tableToSection = { 2455 'installer_payments': 'installer', 2456 'vendor_payment_tracking': 'vendor', 2457 'rep_deductions': 'deductions', 2458 'store_credits': 'credits', 2459 'client_refunds': 'refunds', 2460 'storage_fees': 'storage', 2461 'office_expenses': 'expenses' 2462 }; 2463 var section = tableToSection[tableName] || tableName.split('_')[0]; 2464 2465 sb.from(tableName).insert([record]).then(function(result) { 2466 if (result.error) { 2467 alert('Error saving record: ' + result.error.message); 2468 return; 2469 } 2470 var formEl = document.getElementById('fin-' + section + '-form'); 2471 if (formEl) formEl.style.display = 'none'; 2472 loadFinanceSection(section); 2473 }).catch(function(err) { 2474 console.error('Save error:', err); 2475 alert('Error saving record'); 2476 }); 2477 }; 2478 2479 // ===== Delete Finance Record ===== 2480 window.deleteFinRecord = function(tableName, recordId) { 2481 if (!confirm('Delete this record?')) return; 2482 2483 sb.from(tableName).delete().eq('id', recordId).then(function(result) { 2484 if (result.error) { 2485 alert('Error deleting record: ' + result.error.message); 2486 return; 2487 } 2488 // Determine section name from table 2489 var section = tableName.split('_')[0]; 2490 if (tableName === 'installer_payments') section = 'installer'; 2491 else if (tableName === 'vendor_payment_tracking') section = 'vendor'; 2492 else if (tableName === 'rep_deductions') section = 'deductions'; 2493 else if (tableName === 'store_credits') section = 'credits'; 2494 else if (tableName === 'client_refunds') section = 'refunds'; 2495 else if (tableName === 'storage_fees') section = 'storage'; 2496 else if (tableName === 'office_expenses') section = 'expenses'; 2497 2498 loadFinanceSection(section); 2499 }).catch(function(err) { 2500 console.error('Delete error:', err); 2501 alert('Error deleting record'); 2502 }); 2503 }; 2504 2505 // ===== Tab Visibility Hook ===== 2506 // Use _onTabSwitch callback instead of wrapping sideNav (avoids conflict with FIX 12 override) 2507 var _prevOnTabSwitch = typeof window._onTabSwitch === 'function' ? window._onTabSwitch : null; 2508 window._onTabSwitch = function(tab) { 2509 if (_prevOnTabSwitch) _prevOnTabSwitch(tab); 2510 if (tab === 'finance') { 2511 renderFinanceHub(); 2512 } 2513 }; 2514 // Also handle direct navigation via hash 2515 if (window.location.hash === '#finance') { 2516 setTimeout(function() { renderFinanceHub(); }, 500); 2517 } 2518 2519 console.log('[patch.js] FIX 13: Finance Hub loaded'); 2520})(); 2521 2522// === FIX 14: New Lead Notifications in Admin === 2523(function() { 2524 'use strict'; 2525 2526 // Create a notifications table in Supabase if not already done 2527 // Table: lead_notifications (id serial, lead_id text, company text, rep_name text, created_at timestamptz, read boolean default false) 2528 2529 // Ensure the table exists (will silently fail if already exists) 2530 (async function ensureNotifTable() { 2531 try { 2532 // Just try to select â if it fails, we'll create inline 2533 var { error } = await sb.from('lead_notifications').select('id').limit(1); 2534 if (error && error.code === '42P01') { 2535 // Table doesn't exist â we'll use activities table as fallback 2536 console.log('[patch.js] lead_notifications table not found, using activities fallback'); 2537 } 2538 } catch(e) { /* ignore */ } 2539 })(); 2540 2541 // ===== Hook into submitLead to create notification ===== 2542 // FIX 39: Replaced lead-count comparison with an ID-set diff filtered by 2543 // assigned_rep. The old `leads.length > prevCount` check fired any time the 2544 // global `leads` array grew during the 2-second setTimeout â including 2545 // updates pushed in by Supabase real-time when another rep added a lead in 2546 // a different session, or even when the rep's own submitLead bailed early 2547 // on validation. That produced phantom ð New Lead Notifications attributed 2548 // to the wrong action. The ID-diff + own-rep filter only fires when a NEW 2549 // lead row that didn't exist before AND belongs to the current rep appears, 2550 // which is the actual condition we want. 2551 var _origSubmitLead = window.submitLead; 2552 if (_origSubmitLead) { 2553 window.submitLead = async function() { 2554 var prevIds = new Set( 2555 (typeof leads !== 'undefined' && leads) 2556 ? leads.map(function(l){ return l.id; }) 2557 : [] 2558 ); 2559 var myRepId = (typeof prof !== 'undefined' && prof) ? prof.id : null; 2560 2561 await _origSubmitLead.apply(this, arguments); 2562 2563 // Small delay to allow loadData to complete 2564 setTimeout(async function() { 2565 try { 2566 if (typeof leads === 'undefined' || !leads || !myRepId) return; 2567 2568 // Find leads that didn't exist before this submitLead AND are 2569 // assigned to this rep â i.e. ones THIS rep just inserted. 2570 var freshOwn = leads.filter(function(l) { 2571 if (!l || !l.id) return false;
2572 if (prevIds.has(l.id)) return false; // existed before submit 2573 if (l.assigned_rep !== myRepId) return false; // someone else's 2574 // Defense in depth: require the lead to have been touched in the 2575 // last 10 seconds, ruling out paginated loads that surface old rows. 2576 var ts = new Date(l.updated_at || l.date_added || 0).getTime(); 2577 if (!ts || (Date.now() - ts) > 10000) return false; 2578 return true; 2579 }); 2580 2581 if (freshOwn.length === 0) return; 2582 2583 var newLead = freshOwn[0]; 2584 await sb.from('lead_notifications').insert({ 2585 lead_id: newLead.id || '', 2586 company: newLead.company || 'Unknown', 2587 rep_name: prof.full_name || 'Unknown', 2588 created_at: new Date().toISOString(), 2589 read: false 2590 }).then(function() { 2591 updateAdminBadge(); 2592 }).catch(function() { 2593 // Fallback: just update badge from activities 2594 updateAdminBadge(); 2595 }); 2596 } catch(e) { console.log('[FIX 39] Notification hook error:', e); } 2597 }, 2000); 2598 }; 2599 } 2600 2601 // ===== Update Admin Badge Count ===== 2602 window.updateAdminBadge = async function() { 2603 try { 2604 var { data, error } = await sb.from('lead_notifications') 2605 .select('id') 2606 .eq('read', false); 2607 2608 if (!error && data) { 2609 var badge = document.getElementById('admin-badge'); 2610 if (badge) { 2611 if (data.length > 0) { 2612 badge.textContent = data.length; 2613 badge.style.display = 'inline-flex'; 2614 } else { 2615 badge.style.display = 'none'; 2616 } 2617 } 2618 } 2619 } catch(e) { /* ignore */ } 2620 }; 2621 2622 // ===== Add Notifications Button to Admin Tab ===== 2623 function addNotifButtonToAdmin() { 2624 var adminBtnRow = document.querySelector('#tab-admin > div:first-child'); 2625 if (!adminBtnRow) return; 2626 // Check if already added 2627 if (document.getElementById('admin-sec-notifications')) return; 2628 2629 // Add notification button 2630 var btn = document.createElement('button'); 2631 btn.id = 'admin-sec-notifications'; 2632 btn.onclick = function() { showAdminSection('notifications'); }; 2633 btn.style.cssText = 'font-size:12px;font-weight:600;padding:8px 14px;border-radius:8px;cursor:pointer;border:1px solid var(--b);background:var(--d3);color:var(--t)'; 2634 btn.innerHTML = 'ð New Leads'; 2635 adminBtnRow.insertBefore(btn, adminBtnRow.firstChild); 2636 2637 // Add notification content div 2638 var contentDiv = document.createElement('div'); 2639 contentDiv.id = 'admin-notifications'; 2640 contentDiv.style.display = 'none'; 2641 var adminTab = document.getElementById('tab-admin'); 2642 if (adminTab) { 2643 // Insert after the button row 2644 adminBtnRow.parentNode.insertBefore(contentDiv, adminBtnRow.nextSibling); 2645 } 2646 } 2647 2648 // ===== Render Notifications Section ===== 2649 window.renderAdminNotifications = async function() { 2650 var container = document.getElementById('admin-notifications'); 2651 if (!container) return; 2652 2653 container.innerHTML = '<div style="color:var(--t2);">Loading notifications...</div>'; 2654 2655 try { 2656 var { data: notifs, error } = await sb.from('lead_notifications') 2657 .select('*') 2658 .order('created_at', { ascending: false }) 2659 .limit(50); 2660 2661 if (error) { 2662 // Fallback: show recent activities of type "New Lead" 2663 var { data: acts } = await sb.from('activities') 2664 .select('*') 2665 .eq('activity_type', 'New Lead') 2666 .order('created_at', { ascending: false }) 2667 .limit(50); 2668 2669 notifs = (acts || []).map(function(a) { 2670 return { 2671 id: a.id, 2672 company: a.summary || '', 2673 rep_name: '', 2674 created_at: a.created_at, 2675 read: true 2676 }; 2677 }); 2678 } 2679 2680 if (!notifs || notifs.length === 0) { 2681 container.innerHTML = '<div style="padding:20px;color:var(--t2);text-align:center;">No new lead notifications.</div>'; 2682 return; 2683 } 2684 2685 var unreadCount = notifs.filter(function(n) { return !n.read; }).length; 2686 2687 var html = '<div class="st">ð New Lead Notifications</div>'; 2688 2689 if (unreadCount > 0) { 2690 html += '<div style="margin-bottom:12px;display:flex;gap:8px;align-items:center;">' 2691 + '<span style="background:#ef4444;color:#fff;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:700;">
2691' + unreadCount + ' unread</span>' 2692 + '<button onclick="markAllNotifsRead()" style="padding:4px 12px;background:var(--gold);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px;font-weight:600;">Mark All Read</button>' 2693 + '</div>'; 2694 } 2695 2696 html += '<div style="display:flex;flex-direction:column;gap:8px;">'; 2697 notifs.forEach(function(n) { 2698 var isUnread = !n.read; 2699 var bg = isUnread ? 'var(--gold)11' : 'var(--d3)'; 2700 var border = isUnread ? '2px solid var(--gold)' : '1px solid var(--b)'; 2701 var timeAgo = getTimeAgo(n.created_at); 2702 2703 html += '<div style="padding:12px 16px;background:' + bg + ';border:' + border + ';border-radius:8px;display:flex;justify-content:space-between;align-items:center;">' 2704 + '<div>' 2705 + '<span style="font-weight:' + (isUnread ? '700' : '400') + ';color:var(--t);font-size:14px;">' + escHtml(n.company) + '</span>' 2706 + (n.rep_name ? '<span style="color:var(--t3);font-size:12px;margin-left:8px;">by ' + escHtml(n.rep_name) + '</span>' : '') 2707 + '</div>' 2708 + '<div style="display:flex;align-items:center;gap:8px;">' 2709 + '<span style="color:var(--t3);font-size:11px;">' + timeAgo + '</span>'; 2710 2711 if (isUnread) { 2712 html += '<button onclick="markNotifRead(' + n.id + ')" style="padding:2px 8px;background:var(--d3);border:1px solid var(--b);border-radius:4px;cursor:pointer;font-size:10px;color:var(--t2);">â</button>'; 2713 } 2714 if (n.lead_id) { 2715 html += '<button onclick="openLead(\'' + n.lead_id + '\')" style="padding:2px 8px;background:var(--gold);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:10px;">View</button>'; 2716 } 2717 html += '</div></div>'; 2718 }); 2719 html += '</div>'; 2720 2721 container.innerHTML = html; 2722 } catch(e) { 2723 container.innerHTML = '<div style="padding:20px;color:var(--t2);">Error loading notifications.</div>'; 2724 console.error('[FIX 14]', e); 2725 } 2726 }; 2727 2728 // ===== Mark single notification read ===== 2729 window.markNotifRead = async function(id) { 2730 try { 2731 await sb.from('lead_notifications').update({ read: true }).eq('id', id); 2732 renderAdminNotifications(); 2733 updateAdminBadge(); 2734 } catch(e) { console.error(e); } 2735 }; 2736 2737 // ===== Mark all notifications read ===== 2738 window.markAllNotifsRead = async function() { 2739 try { 2740 await sb.from('lead_notifications').update({ read: true }).eq('read', false); 2741 renderAdminNotifications(); 2742 updateAdminBadge(); 2743 } catch(e) { console.error(e); } 2744 }; 2745 2746 // ===== Hook showAdminSection to handle notifications ===== 2747 var _origShowAdminSection = window.showAdminSection; 2748 if (_origShowAdminSection) { 2749 window.showAdminSection = function(section) { 2750 // Hide notification section when switching to other sections 2751 var notifDiv = document.getElementById('admin-notifications'); 2752 if (notifDiv) notifDiv.style.display = 'none'; 2753 2754 // Reset notification button style 2755 var notifBtn = document.getElementById('admin-sec-notifications'); 2756 if (notifBtn) { 2757 notifBtn.style.background = 'var(--d3)'; 2758 notifBtn.style.color = 'var(--t)'; 2759 notifBtn.style.borderColor = 'var(--b)'; 2760 } 2761 2762 if (section === 'notifications') { 2763 // Hide all other admin sections 2764 _origShowAdminSection('__none__'); // triggers hiding all sections 2765 if (notifDiv) notifDiv.style.display = 'block'; 2766 if (notifBtn) { 2767 notifBtn.style.background = 'var(--sf-blue)'; 2768 notifBtn.style.color = '#fff'; 2769 notifBtn.style.borderColor = 'var(--sf-blue)'; 2770 } 2771 renderAdminNotifications(); 2772 } else { 2773 _origShowAdminSection(section); 2774 } 2775 }; 2776 } 2777 2778 // ===== Helpers ===== 2779 function escHtml(str) { 2780 if (!str) return ''; 2781 var d = document.createElement('div'); 2782 d.textContent = str; 2783 return d.innerHTML; 2784 } 2785 2786 function getTimeAgo(dateStr) { 2787 if (!dateStr) return ''; 2788 var now = new Date(); 2789 var then = new Date(dateStr); 2790 var diff = Math.floor((now - then) / 1000); 2791 if (diff < 60) return 'just now'; 2792 if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; 2793 if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; 2794 if (diff < 604800) return Math.floor(diff / 86400) + 'd ago'; 2795 return fmtDate(dateStr); 2796 } 2797 2798 // ===== Init =====
2799 function init() { 2800 addNotifButtonToAdmin(); 2801 // Create the lead_notifications table if it doesn't exist 2802 // (Using raw SQL via Supabase's rpc or just try insert) 2803 updateAdminBadge(); 2804 2805 // Poll for new notifications every 60 seconds 2806 setInterval(function() { 2807 if (typeof prof !== 'undefined' && prof && prof.role === 'admin') { 2808 updateAdminBadge(); 2809 } 2810 }, 60000); 2811 } 2812 2813 if (document.readyState === 'loading') { 2814 document.addEventListener('DOMContentLoaded', function() { setTimeout(init, 500); }); 2815 } else { 2816 setTimeout(init, 500); 2817 } 2818 2819 console.log('[patch.js] FIX 14: New Lead Notifications loaded'); 2820})(); 2821 2822 2823// === FIX 15: Inline Commission & Installer Pay on Invoices/Work Orders === 2824(function() { 2825 'use strict'; 2826 2827 // ===== Inject commission info into invoice detail views ===== 2828 // When an invoice is opened (the detail overlay), add a commission summary card 2829 2830 function createCommissionBadge(repName, totalSale, costAmount, commissionPct) { 2831 var profit = (totalSale || 0) - (costAmount || 0); 2832 var ratePct = parseFloat(commissionPct || 30); 2833 var rate = ratePct > 1 ? ratePct / 100 : ratePct; 2834 var commission = profit * rate; 2835 if (profit < 0) profit = 0; 2836 if (commission < 0) commission = 0; 2837 2838 var badge = document.createElement('div'); 2839 badge.className = 'fin-inline-badge'; 2840 badge.style.cssText = 'margin:10px 0;padding:12px 16px;background:linear-gradient(135deg,#f8f4ef,#faf7f2);border:1px solid var(--gold);border-radius:8px;display:flex;gap:20px;align-items:center;flex-wrap:wrap;'; 2841 badge.innerHTML = '<div style="display:flex;align-items:center;gap:6px;">' 2842 + '<span style="font-size:16px;">ð°</span>' 2843 + '<span style="font-weight:700;color:var(--t);font-size:13px;">Commission</span></div>' 2844 + '<div style="font-size:12px;color:var(--t2);">Rep: <strong style="color:var(--t);">' + escH(repName || 'Unassigned') + '</strong></div>' 2845 + '<div style="font-size:12px;color:var(--t2);">Profit: <strong style="color:' + (profit > 0 ? '#16a34a' : '#ef4444') + ';">' + fmtM(profit) + '</strong></div>' 2846 + '<div style="font-size:12px;color:var(--t2);">Commission (' + Math.round((commissionRate || 0.30) * 100) + '%): <strong style="color:var(--gold);">' + fmtM(commission) + '</strong></div>'; 2847 return badge; 2848 } 2849 2850 function createInstallerBadge(installerName, sqft, ratePerSqft) { 2851 var totalPay = (sqft || 0) * (ratePerSqft || 0); 2852 var badge = document.createElement('div'); 2853 badge.className = 'fin-inline-badge'; 2854 badge.style.cssText = 'margin:10px 0;padding:12px 16px;background:linear-gradient(135deg,#eff6ff,#f0f7ff);border:1px solid #3b82f6;border-radius:8px;display:flex;gap:20px;align-items:center;flex-wrap:wrap;'; 2855 badge.innerHTML = '<div style="display:flex;align-items:center;gap:6px;">' 2856 + '<span style="font-size:16px;">ð§</span>' 2857 + '<span style="font-weight:700;color:var(--t);font-size:13px;">Installer Pay</span></div>' 2858 + '<div style="font-size:12px;color:var(--t2);">Installer: <strong style="color:var(--t);">' + escH(installerName || 'TBD') + '</strong></div>' 2859 + '<div style="font-size:12px;color:var(--t2);">Sqft: <strong style="color:var(--t);">' + (sqft ? sqft.toLocaleString() : '0') + '</strong></div>' 2860 + '<div style="font-size:12px;color:var(--t2);">Rate: <strong style="color:var(--t);">' + fmtM(ratePerSqft) + '/sqft</strong></div>' 2861 + '<div style="font-size:12px;color:var(--t2);">Total: <strong style="color:#3b82f6;">' + fmtM(totalPay) + '</strong></div>'; 2862 return badge; 2863 } 2864 2865 function fmtM(n) { 2866 if (!n && n !== 0) return '$0.00'; 2867 return '$' + parseFloat(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 2868 } 2869 2870 function escH(s) { 2871 if (!s) return ''; 2872 var d = document.createElement('div'); 2873 d.textContent = s; 2874 return d.innerHTML; 2875 } 2876 2877 // ===== Observer to inject badges into invoice/WO detail views ===== 2878 async function getRepConfig(repName) { 2879 if (!repName) return { commission_pct: 30 }; 2880 try { 2881 var { data } = await sb.from('rep_pay_config').select('*').ilike('rep_name', '%' + repName.split(' ')[0] + '%').limit(1); 2882 if (data && data.length > 0) return data[0]; 2883 } catch(e) { /* ignore */ } 2884 return { commission_pct: 30 }; 2885 } 2886 2887 async function getInstallerPayment(woId) { 2888 if (!woId) return null; 2889 try { 2890 var { data } = await sb.from('installer_payments').select('*').eq('work_order_id', woId).limit(1); 2891 if (data && data.length > 0) return data[0]; 2892 } catch(e) { /* ignore */ } 2893 return null; 2894 } 2895 2896 // Watch for invoice detail overlays 2897 var observer = new MutationObserver(function(mutations) { 2898 mutations.forEach(function(mut) {
2899 mut.addedNodes.forEach(function(node) { 2900 if (node.nodeType !== 1) return; 2901 2902 // Check for invoice detail view â look for elements with invoice data 2903 var invoiceHeader = node.querySelector ? node.querySelector('.inv-detail-header, [data-invoice-id]') : null; 2904 if (!invoiceHeader && node.classList && (node.classList.contains('ov-body') || node.id === 'inv-detail')) { 2905 invoiceHeader = node; 2906 } 2907 2908 // Also check for work order detail views 2909 var woHeader = node.querySelector ? node.querySelector('.wo-detail-header, [data-wo-id]') : null; 2910 if (!woHeader && node.classList && node.id === 'wo-detail') { 2911 woHeader = node; 2912 } 2913 }); 2914 }); 2915 }); 2916 2917 // Use a simpler approach: periodically check for open detail views and inject badges 2918 var lastInvId = null; 2919 var lastWoId = null; 2920 2921 setInterval(function() { 2922 if (typeof prof === 'undefined' || !prof || prof.role !== 'admin') return; 2923 2924 // Check for open invoice detail 2925 var invDetail = document.getElementById('inv-detail') || document.querySelector('.invoice-detail-view'); 2926 if (invDetail && invDetail.offsetParent !== null) { 2927 var invId = invDetail.getAttribute('data-invoice-id') || invDetail.dataset?.invoiceId; 2928 if (!invId) { 2929 // Try to find it from content 2930 var idEl = invDetail.querySelector('[data-inv-id]'); 2931 if (idEl) invId = idEl.getAttribute('data-inv-id'); 2932 } 2933 // Only inject once per view 2934 if (!invDetail.querySelector('.fin-inline-badge')) { 2935 injectInvoiceFinance(invDetail); 2936 } 2937 } 2938 2939 // Check for open work order detail 2940 var woDetail = document.getElementById('wo-detail') || document.querySelector('.wo-detail-view'); 2941 if (woDetail && woDetail.offsetParent !== null) { 2942 if (!woDetail.querySelector('.fin-inline-badge')) { 2943 injectWOFinance(woDetail); 2944 } 2945 } 2946 }, 2000); 2947 2948 async function injectInvoiceFinance(container) { 2949 // Find invoice data from the visible content 2950 var repEl = container.querySelector('[data-field="sales_rep"], .inv-rep'); 2951 var totalEl = container.querySelector('[data-field="total_sale"], .inv-total'); 2952 var costEl = container.querySelector('[data-field="cost_amount"], .inv-cost'); 2953 2954 // Fallback: scan text content for rep name and amounts 2955 var repName = ''; 2956 var totalSale = 0; 2957 var costAmount = 0; 2958 2959 if (repEl) repName = repEl.textContent.trim(); 2960 if (totalEl) totalSale = parseFloat(totalEl.textContent.replace(/[^0-9.-]/g, '')) || 0; 2961 if (costEl) costAmount = parseFloat(costEl.textContent.replace(/[^0-9.-]/g, '')) || 0; 2962 2963 // If we couldn't find data from elements, try the global invoices array 2964 if (typeof invoices !== 'undefined' && invoices) { 2965 // Find the currently viewed invoice 2966 var visibleInvNum = container.querySelector('h2, h3, .st'); 2967 if (visibleInvNum) { 2968 var invText = visibleInvNum.textContent; 2969 var matchInv = invoices.find(function(inv) { 2970 return invText.indexOf(inv.id) > -1 || invText.indexOf(inv.invoice_number) > -1; 2971 }); 2972 if (matchInv) { 2973 repName = repName || matchInv.sales_rep || ''; 2974 totalSale = totalSale || matchInv.total_sale || 0; 2975 costAmount = costAmount || matchInv.cost_amount || 0; 2976 } 2977 } 2978 } 2979 2980 var config = await getRepConfig(repName); 2981 var badge = createCommissionBadge(repName, totalSale, costAmount, config.commission_pct || 30); 2982 2983 // Insert at top of detail view 2984 var firstChild = container.querySelector('.st, h2, h3'); 2985 if (firstChild && firstChild.nextSibling) { 2986 firstChild.parentNode.insertBefore(badge, firstChild.nextSibling); 2987 } else { 2988 container.insertBefore(badge, container.firstChild); 2989 } 2990 } 2991 2992 async function injectWOFinance(container) { 2993 // Find work order data 2994 var woId = container.getAttribute('data-wo-id') || ''; 2995 var installerEl = container.querySelector('[data-field="installer"], .wo-installer'); 2996 var sqftEl = container.querySelector('[data-field="sqft"], .wo-sqft'); 2997 2998 var installerName = installerEl ? installerEl.textContent.trim() : ''; 2999 var sqft = sqftEl ? parseFloat(sqftEl.textContent.replace(/[^0-9.
2999-]/g, '')) || 0 : 0; 3000 3001 // Try global work_orders array 3002 if (typeof work_orders !== 'undefined' && work_orders) { 3003 var visibleWoNum = container.querySelector('h2, h3, .st'); 3004 if (visibleWoNum) { 3005 var woText = visibleWoNum.textContent; 3006 var matchWo = work_orders.find(function(wo) { 3007 return woText.indexOf(wo.id) > -1 || woText.indexOf(wo.wo_number) > -1; 3008 }); 3009 if (matchWo) { 3010 woId = woId || matchWo.id; 3011 installerName = installerName || matchWo.installer || ''; 3012 sqft = sqft || matchWo.sqft || matchWo.total_sqft || 0; 3013 } 3014 } 3015 } 3016 3017 // Get installer payment record 3018 var payment = await getInstallerPayment(woId); 3019 var ratePerSqft = payment ? payment.rate_per_sqft : 0; 3020 3021 if (installerName || sqft) { 3022 var badge = createInstallerBadge(installerName, sqft, ratePerSqft); 3023 var firstChild = container.querySelector('.st, h2, h3'); 3024 if (firstChild && firstChild.nextSibling) { 3025 firstChild.parentNode.insertBefore(badge, firstChild.nextSibling); 3026 } else { 3027 container.insertBefore(badge, container.firstChild); 3028 } 3029 } 3030 } 3031 3032 console.log('[patch.js] FIX 15: Inline Finance badges loaded'); 3033})(); 3034 3035// === FIX 16: Outlook calendar sync â match button didn't update the calendar === 3036// Ashley's bug: clicking "Match" on unmatched Outlook events in the review panel 3037// inserted the activity into Supabase but never refreshed the local `acts` array 3038// or called renderCalendar(), so the newly matched Friday events didn't appear on 3039// the calendar until a full page refresh. Also fixes matchAllSelected() which was 3040// checking the wrong DOM id (um-match-N instead of um-search-N). 3041(function fixOutlookMatchRefresh() { 3042 if (typeof window.matchSingleEvent !== 'function') { 3043 console.log('[patch.js] FIX 16: matchSingleEvent not found, skipping'); 3044 return; 3045 } 3046 var _origMatchSingle = window.matchSingleEvent; 3047 3048 // Wrap matchSingleEvent â after the original does its insert + row UI update, 3049 // refresh the local acts array and re-render the calendar so the matched 3050 // event immediately appears on the correct date. 3051 window.matchSingleEvent = async function(idx, outlookId, repId) { 3052 var input = document.getElementById('um-search-' + idx); 3053 var hadMatch = !!(input && input.dataset && input.dataset.matchId); 3054 // Delegate to the original (does the insert + row status update) 3055 var result = await _origMatchSingle.apply(this, arguments); 3056 // Only refresh if the user actually matched something (not a skip) 3057 if (hadMatch && typeof sb !== 'undefined' && typeof sb.from === 'function') { 3058 try { 3059 var resp = await sb.from('activities').select('*') 3060 .order('created_at', { ascending: false }).range(0, 4999); 3061 if (resp && resp.data) { 3062 acts = resp.data; 3063 if (typeof renderCalendar === 'function') renderCalendar(); 3064 // If the lead detail pane is open for this lead, refresh it too 3065 if (typeof aLead !== 'undefined' && aLead && typeof renderLead === 'function') { 3066 try { renderLead(); } catch (e) {} 3067 } 3068 } 3069 } catch (e) { 3070 console.warn('[patch.js] FIX 16: acts refresh failed', e); 3071 } 3072 } 3073 return result; 3074 }; 3075 3076 // matchAllSelected was checking document.getElementById('um-match-' + i) which 3077 // never existed â the input id is 'um-search-' + i. Replace it with a version 3078 // that reads input.dataset.matchId (set by umSelectMatch when the user picks 3079 // a dropdown option). Only matches rows where the user has actually selected 3080 // something. 3081 window.matchAllSelected = async function() { 3082 var events = window._unmatchedEvents || []; 3083 var repId = window._unmatchedRepId; 3084 var count = 0; 3085 for (var i = 0; i < events.length; i++) { 3086 var input = document.getElementById('um-search-' + i); 3087 if (!input || !input.dataset || !input.dataset.matchId) continue; 3088 // Delegate one at a time to the wrapped matchSingleEvent 3089 // Note: we call _origMatchSingle directly here to avoid N redundant 3090 // acts-refreshes â we'll do one refresh at the end.
3091 try { 3092 await _origMatchSingle(i, events[i].id, repId); 3093 count++; 3094 } catch (e) { 3095 console.warn('[patch.js] FIX 16: match failed for row ' + i, e); 3096 } 3097 } 3098 if (count > 0) { 3099 try { 3100 var resp = await sb.from('activities').select('*') 3101 .order('created_at', { ascending: false }).range(0, 4999); 3102 if (resp && resp.data) acts = resp.data; 3103 } catch (e) { 3104 console.warn('[patch.js] FIX 16: bulk acts refresh failed', e); 3105 } 3106 if (typeof renderCalendar === 'function') renderCalendar(); 3107 if (typeof toast === 'function') toast('Matched ' + count + ' events â', 'ok'); 3108 } else { 3109 if (typeof toast === 'function') toast('Pick a lead/vendor/installer for at least one event first', 'info'); 3110 } 3111 }; 3112 3113 console.log('[patch.js] FIX 16: Outlook match-event calendar refresh applied'); 3114})(); 3115 3116// === FIX 17: Route Planner â Apple Maps, Portal Map, and Save/Load routes === 3117// Joey's report: 3118// (a) Apple Maps + Portal Map buttons not working 3119// (b) Routes built on desktop don't carry over to mobile
3120// 3121// Three fixes: 3122// 1. Apple Maps URL: was http:// (blocked as mixed content on HTTPS) and 3123// only passed one destination â all waypoints were dropped. Now uses 3124// https:// and chains stops with "+to:" separator. 3125// 2. Portal Map: was using placeholder Google API key ("AIzaSyD-placeholder") 3126// so the directions embed silently failed. Replaced with an OSM tile 3127// centered on the route bbox plus a clean numbered stop list and direct 3128// Google/Apple Maps launch buttons. 3129// 3. Persistence: routeStops was in-memory only. Added ð¾ Save / ð Load 3130// buttons that hit a new `rep_routes` table (per-rep, JSONB stops). 3131// Feature-detects â if the table doesn't exist yet, buttons show a 3132// "Ask Joey to apply the SQL migration" message instead of erroring. 3133// 3134// Companion SQL migration is in /sessions/amazing-elegant-rubin/mnt/outputs/ 3135// 13-rep-routes-migration.sql â apply once in Supabase SQL Editor and the 3136// Save/Load buttons will start working for all reps. 3137(function fixRoutePlanner() { 3138 // ---- (1) Replace launchRoute with corrected Apple + Portal behavior ---- 3139 if (typeof window.launchRoute !== 'function') { 3140 console.log('[patch.js] FIX 17: launchRoute not found, skipping'); 3141 return; 3142 } 3143 3144 window.launchRoute = async function(type) { 3145 if (!Array.isArray(routeStops) || !routeStops.length) { 3146 if (typeof toast === 'function') toast('Add at least one stop first', 'err'); 3147 return; 3148 } 3149 // Preserve the existing optimize-before-launch behavior 3150 if (typeof optimizeRouteOrder === 'function') { 3151 try { await optimizeRouteOrder(); } catch (e) {} 3152 } 3153 3154 // Current location 3155 var origin = ''; 3156 try { 3157 var pos = await new Promise(function(res, rej) { 3158 navigator.geolocation.getCurrentPosition(res, rej, { timeout: 6000 }); 3159 }); 3160 origin = pos.coords.latitude + ',' + pos.coords.longitude; 3161 } catch (e) { 3162 if (typeof toast === 'function') toast('Could not get your location â showing route without it', 'info'); 3163 } 3164 3165 var stops = routeStops.map(function(s) { return s.address || s.company; }).filter(Boolean); 3166 if (!stops.length) { 3167 if (typeof toast === 'function') toast('Stops have no addresses', 'err'); 3168 return; 3169 } 3170 var dest = stops[stops.length - 1]; 3171 var waypoints = stops.slice(0, -1); 3172 3173 if (type === 'google') { 3174 var gUrl = 'https://www.google.com/maps/dir/?api=1' 3175 + (origin ? '&origin=' + encodeURIComponent(origin) : '') 3176 + '&destination=' + encodeURIComponent(dest) 3177 + (waypoints.length ? '&waypoints=' + encodeURIComponent(waypoints.join('|')) : ''); 3178 window.open(gUrl, '_blank'); 3179 return; 3180 } 3181 3182 if (type === 'apple') { 3183 // FIX: https (was http â mixed-content blocked) + chain all waypoints 3184 // with "+to:" so Apple Maps hits every stop, not just the last one. 3185 var aDaddr = [].concat(waypoints, [dest]) 3186 .map(function(a) { return encodeURIComponent(a); }) 3187 .join('+to:'); 3188 var aUrl = 'https://maps.apple.com/?' 3189 + (origin ? 'saddr=' + encodeURIComponent(origin) + '&' : '') 3190 + 'daddr=' + aDaddr; 3191 window.open(aUrl, '_blank'); 3192 return; 3193 } 3194 3195 if (type === 'portal') { 3196 // FIX: render a usable portal preview with OSM (no API key needed) 3197 // plus a clean numbered stop list and launch buttons. Previous code 3198 // showed only the destination because the Google embed key was a 3199 // placeholder string. 3200 var frameEl = document.getElementById('portal-route-map-frame'); 3201 if (!frameEl) return; 3202 3203 // Compute a rough bbox from known ZIP/CITY coordinates of the stops 3204 var coords = []; 3205 routeStops.forEach(function(s) { 3206 var lead = (typeof leads !== 'undefined' ? leads : []).find(function(l) { return l.id === s.id; }); 3207 if (!lead) return; 3208 var zip = (lead.zip_code || '').toString().substring(0, 5); 3209 var city = (lead.city || '').trim(); 3210 if (typeof ZIP_COORDS !== 'undefined' && ZIP_COORDS[zip]) coords.push(ZIP_COORDS[zip]); 3211 else if (typeof CITY_COORDS !== 'undefined' && CITY_COORDS[city]) coords.push(CITY_COORDS[city]); 3212 }); 3213 var centerLat = 29.76, centerLng = -95.37, bboxPad = 0.3; // Houston default 3214 if (coords.length) {
3215 var lats = coords.map(function(c) { return c[0]; }); 3216 var lngs = coords.map(function(c) { return c[1]; }); 3217 var minLat = Math.min.apply(null, lats), maxLat = Math.max.apply(null, lats); 3218 var minLng = Math.min.apply(null, lngs), maxLng = Math.max.apply(null, lngs); 3219 centerLat = (minLat + maxLat) / 2; 3220 centerLng = (minLng + maxLng) / 2; 3221 bboxPad = Math.max(0.08, Math.max(maxLat - minLat, maxLng - minLng) * 0.6 + 0.05); 3222 } 3223 var mapSrc = 'https://www.openstreetmap.org/export/embed.html' 3224 + '?bbox=' + (centerLng - bboxPad) + ',' + (centerLat - bboxPad) + ',' 3225 + (centerLng + bboxPad) + ',' + (centerLat + bboxPad) 3226 + '&layer=mapnik&marker=' + centerLat + ',' + centerLng; 3227 3228 var gBtnUrl = 'https://www.google.com/maps/dir/?api=1' 3229 + (origin ? '&origin=' + encodeURIComponent(origin) : '') 3230 + '&destination=' + encodeURIComponent(dest) 3231 + (waypoints.length ? '&waypoints=' + encodeURIComponent(waypoints.join('|')) : ''); 3232 var aBtnDaddr = [].concat(waypoints, [dest]).map(function(a) { return encodeURIComponent(a); }).join('+to:'); 3233 var aBtnUrl = 'https://maps.apple.com/?' + (origin ? 'saddr=' + encodeURIComponent(origin) + '&' : '') + 'daddr=' + aBtnDaddr; 3234 3235 var stopsHtml = routeStops.map(function(s, i) { 3236 var esc = function(t) { return (t || '').toString().replace(/</g, '<'); }; 3237 return '<div style="display:flex;gap:8px;align-items:flex-start;padding:8px 0;border-bottom:1px solid var(--b)">' 3238 + '<div style="background:var(--gold);color:#1a1008;border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;flex-shrink:0">' + (i + 1) + '</div>' 3239 + '<div style="flex:1;min-width:0">' 3240 + '<div style="font-weight:600;font-size:13px;color:var(--t)">' + esc(s.company) + '</div>' 3241 + (s.address ? '<div style="font-size:11px;color:var(--t3)">' + esc(s.address) + '</div>' : '') 3242 + (s.phone ? '<div style="font-size:11px;color:var(--t3)">ð ' + esc(s.phone) + '</div>' : '') 3243 + '</div>' 3244 + '</div>'; 3245 }).join(''); 3246 3247 frameEl.innerHTML = 3248 '<iframe src="' + mapSrc + '" style="width:100%;height:280px;border:0;border-radius:10px" loading="lazy"></iframe>' 3249 + '<div style="margin-top:12px">' 3250 + '<div style="font-size:11px;color:var(--t3);margin-bottom:4px">Your route (' + routeStops.length + ' stop' + (routeStops.length !== 1 ? 's' : '') + '):</div>' 3251 + stopsHtml 3252 + '</div>' 3253 + '<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">' 3254 + '<a href="' + gBtnUrl + '" target="_blank" style="flex:1;min-width:140px;text-align:center;background:var(--gold);color:#1a1008;padding:12px;border-radius:8px;font-weight:700;text-decoration:none">ðºï¸ Open in Google Maps</a>' 3255 + '<a href="' + aBtnUrl + '" target="_blank" style="flex:1;min-width:140px;text-align:center;background:var(--d4);color:var(--t);border:1px solid var(--b);padding:12px;border-radius:8px;font-weight:700;text-decoration:none">ð Open in Apple Maps</a>' 3256 + '</div>'; 3257 var ov = document.getElementById('ov-route-map'); 3258 if (ov) ov.style.display = 'flex'; 3259 return; 3260 } 3261 }; 3262 3263 // ---- (2) Add Save Route / Load Saved Route buttons + functions ---- 3264 // New functions attached to window so inline onclick works. 3265 3266 window.saveCurrentRoute = async function() { 3267 if (!Array.isArray(routeStops) || !routeStops.length) { 3268 if (typeof toast === 'function') toast('Add at least one stop first', 'err'); 3269 return; 3270 } 3271 if (typeof sb === 'undefined' || typeof prof === 'undefined' || !prof) { 3272 if (typeof toast === 'function') toast('Not signed in', 'err'); 3273 return; 3274 } 3275 var name = window.prompt('Name this route (e.g. "Tuesday Memorial run"):', ''); 3276 if (!name) return; 3277 name = name.trim(); 3278 if (!name) return; 3279 try { 3280 var resp = await sb.from('rep_routes').insert({ 3281 rep_id: prof.id, 3282 name: name, 3283 stops: routeStops 3284 }).select(); 3285 if (resp.error) { 3286 // Table missing or RLS denied â guide user 3287 if (resp.error.code === '42P01' || /relation .* does not exist/i.test(resp.error.message || '')) { 3288 if (typeof toast === 'function') toast('Saved Routes feature needs DB migration â ask Joey to apply rep_routes-migration.sql', 'err'); 3289 } else { 3290 if (typeof toast === 'function') toast('Save failed: ' + resp.error.message, 'err'); 3291 } 3292 console.warn('[patch.js] FIX 17: saveCurrentRoute error', resp.error); 3293 return; 3294 } 3295 if (typeof toast === 'function') toast('Route "' + name + '" saved â', 'ok'); 3296 } catch (e) { 3297 console.warn('[patch.js] FIX 17: saveCurrentRoute exception', e); 3298 if (typeof toast === 'function') toast('Save failed â check console', 'err'); 3299 } 3300 }; 3301 3302 window.openSavedRoutes = async function() { 3303 if (typeof sb === 'undefined' || typeof prof === 'undefined' || !prof) { 3304 if (typeof toast === 'function') toast('Not signed in', 'err'); 3305 return; 3306 } 3307 var resp; 3308 try { 3309 resp = await sb.from('rep_routes') 3310 .select('id,name,stops,created_at') 3311 .eq('rep_id', prof.id) 3312 .order('created_at', { ascending: false }); 3313 } catch (e) { 3314 console.warn('[patch.js] FIX 17: openSavedRoutes exception', e); 3315 if (typeof toast === 'function') toast('Load failed â check console', 'err'); 3316 return; 3317 } 3318 if (resp.error) { 3319 if (resp.error.code === '42P01' || /relation .* does not exist/i.test(resp.error.message || '')) { 3320 if (typeof toast === 'function') toast('Saved Routes feature needs DB migration â ask Joey to apply rep_routes-migration.sql', 'err'); 3321 } else { 3322 if (typeof toast === 'function') toast('Load failed: ' + resp.error.message, 'err'); 3323 } 3324 return; 3325 } 3326 var rows = resp.data || []; 3327 if (!rows.length) { 3328 if (typeof toast === 'function') toast('No saved routes yet. Hit ð¾ Save first.', 'info'); 3329 return; 3330 } 3331 var existing = document.getElementById('ov-saved-routes'); 3332 if (existing) existing.remove(); 3333 var esc = function(t) { return (t || '').toString().replace(/</g, '<').replace(/'/g, '''); }; 3334 var rowsHtml = rows.map(function(r) { 3335 var count = Array.isArray(r.stops) ? r.stops.length : 0; 3336 var when = r.created_at ? new Date(r.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : ''; 3337 return '<div style="background:var(--d4);border-radius:10px;padding:12px;margin-bottom:8px;display:flex;gap:10px;align-items:center">' 3338 + '<div style="flex:1;min-width:0">' 3339 + '<div style="font-weight:700;font-size:13px;color:var(--t)">' + esc(r.name) + '</div>' 3340 + '<div style="font-size:11px;color:var(--t3)">' + count + ' stop' + (count !== 1 ? 's' : '') + ' · ' + when + '</div>' 3341 + '</div>' 3342 + '<button onclick="loadSavedRoute(\'' + r.id + '\')" style="background:var(--gold);
3342color:#1a1008;border:none;border-radius:6px;padding:6px 12px;font-size:11px;font-weight:700;cursor:pointer">Load</button>' 3343 + '<button onclick="deleteSavedRoute(\'' + r.id + '\')" style="background:transparent;border:1px solid var(--b);color:var(--red);border-radius:6px;padding:6px 10px;font-size:11px;cursor:pointer" title="Delete">ðï¸</button>' 3344 + '</div>'; 3345 }).join(''); 3346 var html = '<div id="ov-saved-routes" style="position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px" onclick="if(event.target===this)this.remove()">' 3347 + '<div style="background:var(--d2);border-radius:16px;padding:20px;max-width:500px;width:100%;max-height:80vh;overflow-y:auto">' 3348 + '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">' 3349 + '<div style="font-size:16px;font-weight:800;color:var(--t)">ð Saved Routes</div>' 3350 + '<button onclick="document.getElementById(\'ov-saved-routes\').remove()" style="background:none;border:none;color:var(--t3);font-size:18px;cursor:pointer">â</button>' 3351 + '</div>' 3352 + rowsHtml 3353 + '</div>' 3354 + '</div>'; 3355 document.body.insertAdjacentHTML('beforeend', html); 3356 window._savedRoutesCache = rows; 3357 }; 3358 3359 window.loadSavedRoute = function(id) { 3360 var rows = window._savedRoutesCache || []; 3361 var r = rows.find(function(x) { return x.id === id; }); 3362 if (!r) { if (typeof toast === 'function') toast('Route not found', 'err'); return; } 3363 if (!Array.isArray(r.stops)) { if (typeof toast === 'function') toast('Route data invali
3363d', 'err'); return; } 3364 routeStops = r.stops.slice(); 3365 var ov = document.getElementById('ov-saved-routes'); if (ov) ov.remove(); 3366 if (typeof renderRouteStops === 'function') renderRouteStops(); 3367 if (typeof renderSuggestedRoutes === 'function') renderSuggestedRoutes(); 3368 if (typeof renderRouteMap === 'function') renderRouteMap(); 3369 if (typeof toast === 'function') toast('Loaded "' + r.name + '" â ' + r.stops.length + ' stops', 'ok'); 3370 }; 3371 3372 window.deleteSavedRoute = async function(id) { 3373 if (!confirm('Delete this saved route?')) return; 3374 try { 3375 var resp = await sb.from('rep_routes').delete().eq('id', id); 3376 if (resp.error) { if (typeof toast === 'function') toast('Delete failed: ' + resp.error.message, 'err'); return; } 3377 if (typeof toast === 'function') toast('Route deleted', 'ok'); 3378 window.openSavedRoutes(); // refresh the list 3379 } catch (e) { 3380 console.warn('[patch.js] FIX 17: deleteSavedRoute exception', e); 3381 } 3382 }; 3383 3384 // ---- (3) Inject Save/Load buttons into the Route Planner UI ---- 3385 // Watch for the Route Planner tab becoming active and add buttons next to 3386 // the existing "Copy List / Clear All" row (avoids modifying portal-index.html). 3387 function injectSaveLoadButtons() { 3388 var stopsBox = document.getElementById('route-stops'); 3389 if (!stopsBox) return; 3390 var container = stopsBox.parentElement; 3391 if (!container || container.querySelector('[data-patch17-btns]')) return; 3392 // Find the button rows â the second one has Copy/Clear 3393 var btnRows = container.querySelectorAll('div[style*="margin-top"]'); 3394 if (!btnRows.length) return; 3395 // Create a new row for Save/Load that appears between the two existing rows 3396 var row = document.createElement('div'); 3397 row.setAttribute('data-patch17-btns', '1'); 3398 row.style.cssText = 'margin-top:8px;display:flex;gap:8px;flex-wrap:wrap'; 3399 row.innerHTML = 3400 '<button onclick="saveCurrentRoute()" style="flex:1;min-width:120px;background:var(--d4);color:var(--t);border:1px solid var(--b);border-radius:8px;padding:8px;font-size:12px;font-weight:600;cursor:pointer">ð¾ Save Route</button>' 3401 + '<button onclick="openSavedRoutes()" style="flex:1;min-width:120px;background:var(--d4);color:var(--t);border:1px solid var(--b);border-radius:8px;padding:8px;font-size:12px;font-weight:600;cursor:pointer">ð Load Saved</button>'; 3402 // Insert after the last existing button row 3403 var lastBtnRow = btnRows[btnRows.length - 1]; 3404 if (lastBtnRow && lastBtnRow.parentElement === container) { 3405 lastBtnRow.parentElement.insertBefore(row, lastBtnRow.nextSibling); 3406 } 3407 } 3408 // Try immediately and after each Route tab open 3409 if (document.readyState !== 'loading') injectSaveLoadButtons(); 3410 document.addEventListener('DOMContentLoaded', injectSaveLoadButtons); 3411 // Also hook into tab changes â Route Planner is rendered on tab activation 3412 var _origGoTab = window.goTab_route_init; 3413 if (typeof _origGoTab === 'function') { 3414 window.goTab_route_init = function() { 3415 var r = _origGoTab.apply(this, arguments); 3416 setTimeout(injectSaveLoadButtons, 50); 3417 return r; 3418 }; 3419 } 3420 3421 console.log('[patch.js] FIX 17: Route Planner Apple Maps + Portal Map + Save/Load applied'); 3422 3423 // === FIX 18: Bust _productCatalogCache every time the material picker opens === 3424 // Prevents stale coverage/pricing from lingering in a rep's tab after 3425 // someone edits product_catalog in Supabase. Added 2026-04-24 â triggered 3426 // by RIVA-1184 showing "1 sf/ctn" when DB was already updated to 16. 3427 (function(){ 3428 const _origPick = typeof openMaterialPicker === 'function' ? openMaterialPicker : null; 3429 if (_origPick) { 3430 window.openMaterialPicker = function(){ 3431 try { window._productCatalogCache = null; } catch(_) {} 3432 return _origPick.apply(this, arguments); 3433 }; 3434 } 3435 const _origEditQuote = typeof editQuote === 'function' ? editQuote : null; 3436 if (_origEditQuote) { 3437 window.editQuote = function(){ 3438 try { window._productCatalogCache = null; } catch(_) {} 3439 return _origEditQuote.apply(this, arguments); 3440 }; 3441 } 3442 console.log('[patch.js] FIX 18: product catalog cache busting applied'); 3443 })(); 3444 3445 // === FIX 19: Hide "1 sf/ctn" badge on pieces sold individually === 3446 // Moldings, stair-noses, adhesives, tapes, slabs all have coverage=1 3447 // because they're sold per piece, not per carton. The carton calculator 3448 // is noise for those products. This runs after every renderLineItems() 3449 // and strips the carton badge for any line where coverage <= 1. 3450 (function(){ 3451 const _origRender = typeof renderLineItems === 'function' ? renderLineItems : null; 3452 if (!_origRender) return; 3453 window.renderLineItems = function(){ 3454 const result = _origRender.apply(this, arguments);
3455 try { 3456 document.querySelectorAll('.item-cartons').forEach(cartonInput => { 3457 const parent = cartonInput.parentElement; 3458 if (!parent) return; 3459 const txt = parent.textContent || ''; 3460 // Match "1 sf/box" or "1 sf/bucket" (the useless coverage=1 render) 3461 if (/\b1\s*sf\/(box|bucket)\b/.test(txt)) { 3462 parent.style.display = 'none'; 3463 } 3464 }); 3465 } catch(_) {} 3466 return result; 3467 }; 3468 console.log('[patch.js] FIX 19: hide useless 1 sf/ctn badge applied'); 3469 })(); 3470 3471 // === FIX 20: Vendors now load from DB on render ========================== 3472 // Root cause: src/17-vendors.js ships a hardcoded VENDORS seed array and 3473 // never had a loadVendorsFromDB function wired in. Saves were writing to 3474 // Supabase correctly, but renderVendors() read from the in-memory seed 3475 // array only â so new vendors disappeared on refresh, edits didn't stick, 3476 // and deletes resurrected the seed row. 3477 // This fix: on every renderVendors(), merge DB rows into VENDORS keyed by 3478 // lowercased name, DB row wins, and stamp the uuid id onto the local 3479 // entry so the docs feature (FIX 21) can attach uploads to a real vendor. 3480 (function(){ 3481 if (typeof window === 'undefined' || typeof sb === 'undefined') return; 3482 3483 let _vendorDbLoaded = false; 3484 let _vendorDbLoading = null; 3485 3486 window.loadVendorsFromDB = async function(force){ 3487 if (force) _vendorDbLoaded = false; 3488 if (_vendorDbLoaded) return; 3489 if (_vendorDbLoading) return _vendorDbLoading; 3490 _vendorDbLoading = (async () => { 3491 try { 3492 const { data, error } = await sb.from('vendors').select('*').order('name'); 3493 if (error) { console.error('[patch.js] FIX 20 vendor load err:', error); return; } 3494 if (!Array.isArray(data)) return; 3495 if (typeof VENDORS === 'undefined' || !Array.isArray(VENDORS)) return; 3496 3497 const byKey = new Map(); 3498 VENDORS.forEach((v, idx) => { if (v && v.name) byKey.set(String(v.name).trim().toLowerCase(), idx); }); 3499 3500 data.forEach(row => { 3501 const key = String(row.name || '').trim().toLowerCase(); 3502 if (!key) return; 3503 const reps = []; 3504 if (row.rep_name || row.rep_email || row.rep_phone) { 3505 reps.push({ name: row.rep_name || '', email: row.rep_email || '', phone: row.rep_phone || '' }); 3506 } 3507 const merged = { 3508 id: row.id, 3509 name: row.name, 3510 address: row.address || '', 3511 acctNum: row.account_number || '', 3512 phone: row.phone || '', 3513 freight: row.freight || '', 3514 products: Array.isArray(row.products) ? row.products : [], 3515 collections: row.collections || '', 3516 pricing: row.pricing || null, 3517 rep_name: row.rep_name || '', 3518 rep_email: row.rep_email || '', 3519 rep_phone: row.rep_phone || '', 3520 reps, 3521 website: row.website || '', 3522 notes: row.notes || '', 3523 status: row.status || 'active', 3524 _fromDb: true 3525 }; 3526 if (byKey.has(key)) { 3527 // Preserve hardcoded pricing/fees if DB row doesn't carry them yet 3528 const existing = VENDORS[byKey.get(key)] || {}; 3529 if (!merged.pricing && existing.pricing) merged.pricing = existing.pricing; 3530 if (!merged.collections && existing.collections) merged.collections = existing.collections; 3531 if (existing.fees) merged.fees = existing.fees; 3532 VENDORS[byKey.get(key)] = merged; 3533 } else { 3534 VENDORS.push(merged); 3535 byKey.set(key, VENDORS.length - 1); 3536 } 3537 }); 3538 _vendorDbLoaded = true; 3539 console.log('[patch.js] FIX 20: merged', data.length, 'vendors from DB'); 3540 } catch(e) { 3541 console.error('[patch.js] FIX 20 loadVendorsFromDB exception:', e); 3542 } finally { 3543 _vendorDbLoading = null; 3544 } 3545 })(); 3546 return _vendorDbLoading; 3547 }; 3548 3549 const _origRender = typeof renderVendors === 'function' ? renderVendors : null; 3550 if (_origRender) { 3551 window.renderVendors = async function(){
3552 if (!_vendorDbLoaded) { try { await window.loadVendorsFromDB(); } catch(_){} } 3553 return _origRender.apply(this, arguments); 3554 }; 3555 } 3556 3557 // After a save, refetch the DB row and stamp its uuid + fields into VENDORS 3558 const _origSave = typeof saveVendor === 'function' ? saveVendor : null; 3559 if (_origSave) { 3560 window.saveVendor = async function(editIdx){ 3561 const nameInput = document.getElementById('vnd-f-name'); 3562 const savedName = nameInput ? String(nameInput.value || '').trim() : ''; 3563 const result = await _origSave.apply(this, arguments); 3564 // Re-pull the canonical row from DB (upsert with onConflict:'name' either inserted or updated) 3565 try { 3566 if (savedName) { 3567 const { data, error } = await sb.from('vendors').select('*').eq('name', savedName).maybeSingle(); 3568 if (!error && data && typeof VENDORS !== 'undefined' && Array.isArray(VENDORS)) { 3569 const key = savedName.toLowerCase(); 3570 const idx = VENDORS.findIndex(v => v && v.name && v.name.toLowerCase() === key); 3571 if (idx >= 0) { 3572 VENDORS[idx].id = data.id; 3573 VENDORS[idx]._fromDb = true; 3574 } 3575 } 3576 } 3577 } catch(_){} 3578 return result; 3579 }; 3580 } 3581 3582 // Delete: make sure DB delete was actually awaited so refreshes reflect it 3583 const _origDelete = typeof deleteVendor === 'function' ? deleteVendor : null; 3584 if (_origDelete) { 3585 window.deleteVendor = async function(idx){ 3586 if (typeof VENDORS !== 'undefined' && VENDORS[idx] && VENDORS[idx].id) { 3587 if (!confirm('Remove this vendor? This cannot be undone.')) return; 3588 try { 3589 const { error } = await sb.from('vendors').delete().eq('id', VENDORS[idx].id); 3590 if (error) { alert('Could not delete vendor: ' + error.message); return; } 3591 } catch(e) { alert('Could not delete vendor: ' + (e.message||e)); return; } 3592 VENDORS.splice(idx, 1); 3593 const modal = document.getElementById('vendor-modal-overlay'); if (modal) modal.remove(); 3594 if (typeof renderVendors === 'function') renderVendors(); 3595 if (typeof toast === 'function') toast('Vendor removed â', 'ok'); 3596 return; 3597 } 3598 // No DB id yet (hardcoded seed row) â fall back to original in-memory splice 3599 return _origDelete.apply(this, arguments); 3600 }; 3601 } 3602 3603 console.log('[patch.js] FIX 20: vendor DB load + save roundtrip + delete applied'); 3604 })(); 3605 3606 // === FIX 21: Vendor documents upload UI ================================== 3607 // Adds a "Documents" section to the vendor edit modal. Uses Supabase storage 3608 // bucket "vendor-docs" and table public.vendor_documents. 3609 (function(){ 3610 if (typeof window === 'undefined' || typeof sb === 'undefined') return; 3611 const BUCKET = 'vendor-docs'; 3612 const DOC_TYPES = ['freight','price-list','install-guide','tech-spec','warranty','cert','msds','cut-sheet','other']; 3613 3614 async function listVendorDocs(vendorId){ 3615 const { data, error } = await sb.from('vendor_documents') 3616 .select('*').eq('vendor_id', vendorId).eq('is_archived', false).order('uploaded_at', { ascending: false }); 3617 if (error) { console.error('[patch.js] FIX 21 list err:', error); return []; } 3618 return data || []; 3619 } 3620 3621 async function uploadVendorDoc(vendorId, file, docType, notes){ 3622 const safe = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); 3623 const uuid = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Date.now()+'-'+Math.random().toString(36).slice(2)); 3624 const path = vendorId + '/' + uuid + '-' + safe; 3625 const up = await sb.storage.from(BUCKET).upload(path, file, { contentType: file.type || undefined, upsert: false }); 3626 if (up.error) throw up.error; 3627 let userId = null; 3628 try { const { data: ud } = await sb.auth.getUser(); userId = ud && ud.user ? ud.user.id : null; } catch(_){} 3629 const row = { 3630 vendor_id: vendorId, file_name: file.name, file_path: path, 3631 doc_type: docType || 'other', mime_type: file.type || null, 3632 size_bytes: file.size || null, notes: notes || null, uploaded_by: userId 3633 }; 3634 const ins = await sb.from('vendor_documents').insert(row).select('*').maybeSingle(); 3635 if (ins.error) { 3636 try { await sb.storage.from(BUCKET).remove([path]); } catch(_){} 3637 throw ins.error; 3638 } 3639 return ins.data; 3640 } 3641 3642 async function signedUrlFor(path, seconds){ 3643 const { data, error } = await sb.storage.from(BUCKET).createSignedUrl(path, seconds || 3600); 3644 if (error) throw error; 3645 return data.signedUrl; 3646 } 3647 3648 window.vendorDocs_open = async function(docId){ 3649 // FIX 38: Pop the new tab SYNCHRONOUSLY on the click, then redirect it 3650 // once the signed URL is ready. If we wait for the awaits (DB lookup + 3651 // createSignedUrl) and call window.open after, browsers have already 3652 // dropped the user-activation flag and silently block the popup â 3653 // which presented as "I clicked Open and nothing happened" for vendor 3654 // PDFs (AcousticMod report). Drop 'noopener' so we can set the new 3655 // tab's location after the awaits resolve. The destination is our own
3656 // signed Supabase storage URL, no XSS surface from window.opener. 3657 const newTab = window.open('about:blank', '_blank'); 3658 try { 3659 const { data: d, error } = await sb.from('vendor_documents').select('file_path').eq('id', docId).maybeSingle(); 3660 if (error || !d) throw error || new Error('Not found'); 3661 const url = await signedUrlFor(d.file_path, 3600); 3662 if (newTab && !newTab.closed) { 3663 newTab.location.href = url; 3664 } else { 3665 // Popup was blocked despite synchronous open â fall back to 3666 // copying the URL so the rep can paste it in a new tab manually. 3667 try { await navigator.clipboard.writeText(url); } catch(_){} 3668 alert('Your browser blocked the popup. The signed link has been copied to your clipboard â paste it into a new tab to view the document.'); 3669 } 3670 } catch(e){ 3671 if (newTab && !newTab.closed) { try { newTab.close(); } catch(_){} } 3672 alert('Could not open document: ' + (e.message || e)); 3673 } 3674 }; 3675 3676 window.vendorDocs_delete = async function(docId, vendorId){ 3677 if (!confirm('Delete this document?')) return; 3678 try { 3679 const { data: d } = await sb.from('vendor_documents').select('file_path').eq('id', docId).maybeSingle(); 3680 if (d && d.file_path) { try { await sb.storage.from(BUCKET).remove([d.file_path]); } catch(_){} } 3681 const { error } = await sb.from('vendor_documents').delete().eq('id', docId); 3682 if (error) throw error; 3683 await window.vendorDocs_render(vendorId); 3684 // FIX 41: keep the vendor list's ð badge count in sync after delete. 3685 if (typeof window.refreshVendorDocBadges === 'function') { 3686 window.refreshVendorDocBadges(); 3687 } 3688 } catch(e){ alert('Could not delete: ' + (e.message||e)); } 3689 }; 3690 3691 window.vendorDocs_upload = async function(vendorId){ 3692 const fileInput = document.getElementById('vdoc-file'); 3693 const typeInput = document.getElementById('vdoc-type'); 3694 const notesInput = document.getElementById('vdoc-notes'); 3695 if (!fileInput || !fileInput.files || !fileInput.files[0]) { alert('Pick a file first'); return; } 3696 const file = fileInput.files[0]; 3697 if (file.size > 26214400) { alert('File too big (25MB max)'); return; } 3698 const btn = document.getElementById('vdoc-upload-btn'); 3699 if (btn) { btn.disabled = true; btn.textContent = 'Uploadingâ¦'; } 3700 try { 3701 await uploadVendorDoc(vendorId, file, typeInput ? typeInput.value : 'other', notesInput ? notesInput.value.trim() : ''); 3702 if (typeof toast === 'function') toast('Document uploaded â', 'ok'); 3703 fileInput.value = ''; if (notesInput) notesInput.value = ''; 3704 await window.vendorDocs_render(vendorId); 3705 // FIX 41: keep the vendor list's ð badge count in sync without 3706 // requiring a full list re-render. 3707 if (typeof window.refreshVendorDocBadges === 'function') { 3708 window.refreshVendorDocBadges(); 3709 } 3710 } catch(e) { 3711 alert('Upload failed: ' + (e.message || e)); 3712 } finally { 3713 if (btn) { btn.disabled = false; btn.textContent = 'Upload'; } 3714 } 3715 }; 3716 3717 window.vendorDocs_render = async function(vendorId){ 3718 const host = document.getElementById('vdoc-list'); 3719 if (!host) return; 3720 host.innerHTML = '<div style="color:var(--t3);font-size:12px;padding:8px;">Loadingâ¦</div>'; 3721 const docs = await listVendorDocs(vendorId); 3722 if (!docs.length) { host.innerHTML = '<div style="color:var(--t3);font-size:12px;padding:8px;">No documents uploaded yet.</div>'; return; } 3723 function fmtSize(b){ if(!b) return ''; if(b<1024) return b+' B'; if(b<1048576) return (b/1024).toFixed(1)+' KB'; return (b/1048576).toFixed(1)+' MB'; } 3724 function fmtDate(s){ try { return new Date(s).toLocaleDateString(); } catch(_){ return ''; } } 3725 host.innerHTML = docs.map(d => ( 3726 '<div style="display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--b);border-radius:6px;margin-bottom:6px;background:var(--d2);">' 3727 + '<div style="flex:1;min-width:0;">' 3728 + '<div style="font-size:13px;font-weight:600;color:var(--t);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + (d.file_name||'(unnamed)') + '</div>' 3729 + '<div style="font-size:11px;color:var(--t3);margin-top:2px;">' 3730 + '<span style="background:var(--d3);padding:2px 6px;border-radius:4px;margin-right:6px;">' + (d.doc_type||'other') + '</span>' 3731 + fmtDate(d.uploaded_at) + ' · ' + fmtSize(d.size_bytes) 3732 + (d.notes ? ' · ' + d.notes.replace(/</g,'<') : '') 3733 + '</div>' 3734 + '</div>' 3735 + '<button onclick="vendorDocs_open(\'' + d.id + '\')" style="background:var(--d3);border:1px solid var(--b);border-radius:6px;padding:6px 10px;font-size:12px;color:var(--t);cursor:pointer;">Open</button>' 3736 + '<button onclick="vendorDocs_delete(\'' + d.id + '\',\'' + vend
3736orId + '\')" style="background:rgba(198,40,40,.08);border:1px solid rgba(198,40,40,.2);border-radius:6px;padding:6px 10px;font-size:12px;color:#c62828;cursor:pointer;">Delete</button>' 3737 + '</div>' 3738 )).join(''); 3739 }; 3740 3741 async function injectDocsSection(){ 3742 const modal = document.getElementById('vendor-modal-overlay'); 3743 if (!modal) return; 3744 if (modal.querySelector('#vdoc-section')) return; 3745 const saveBtn = modal.querySelector('button[onclick^="saveVendor"]'); 3746 const buttonRow = saveBtn ? saveBtn.parentElement : null; 3747 if (!buttonRow) return; 3748 3749 // FIX 40: insert a placeholder section IMMEDIATELY (synchronously) so the 3750 // MutationObserver's next tick sees #vdoc-section and skips re-running 3751 // while we go async to look up the vendor id from the DB. 3752 const section = document.createElement('div'); 3753 section.id = 'vdoc-section'; 3754 section.style.cssText = 'margin-top:20px;padding-top:16px;border-top:1px solid var(--b);'; 3755 section.innerHTML = '<div style="font-weight:600;font-size:13px;color:var(--t);margin-bottom:8px;">Documents</div>' 3756 + '<div style="color:var(--t3);font-size:12px;padding:10px;">Loading documentsâ¦</div>'; 3757 buttonRow.parentNode.insertBefore(section, buttonRow); 3758 3759 // Determine vendor context: parse editIdx from save button onclick 3760 const oc = saveBtn.getAttribute('onclick') || ''; 3761 const m = oc.match(/saveVendor\((\d+|null)\)/); 3762 const editIdx = m ? (m[1] === 'null' ? null : parseInt(m[1],10)) : null; 3763 const vendor = (editIdx !== null && typeof VENDORS !== 'undefined') ? VENDORS[editIdx] : null; 3764 let vendorId = vendor && vendor.id ? vendor.id : null; 3765 3766 // FIX 40: If the in-memory VENDORS entry has no id (vendors that exist in 3767 // the hardcoded src/17-vendors.js list but don't have a corresponding 3768 // public.vendors row matched by name), fall back to a name-based DB 3769 // lookup. Without this, the placeholder said "Save this vendor firstâ¦" 3770 // and the rep could never upload â even though the vendor existed in 3771 // the DB and just wasn't merged into VENDORS by name. Stamps the 3772 // resolved id back onto the in-memory entry so subsequent opens are 3773 // immediate. 3774 if (!vendorId && vendor && vendor.name) { 3775 try { 3776 const { data, error } = await sb.from('vendors').select('id').eq('name', vendor.name).maybeSingle(); 3777 if (!error && data && data.id) { 3778 vendor.id = data.id; 3779 vendor._fromDb = true; 3780 vendorId = data.id; 3781 } 3782 } catch(_) { /* swallow â falls through to upsert below */ } 3783 } 3784 3785 // FIX 42: If the vendor STILL has no id after the name lookup, that 3786 // means it lives only in the hardcoded src/17-vendors.js seed list 3787 // and has never been written to public.vendors. The previous flow 3788 // forced reps to click Save Changes first, close the modal, then 3789 // reopen â a 2-round-trip workflow just to attach a PDF. Instead, 3790 // upsert a row right now using the data already in memory (same 3791 // fields saveVendor() persists) so the upload UI appears on first 3792 // open. onConflict:'name' makes this idempotent across reps. 3793 if (!vendorId && vendor && vendor.name) { 3794 try { 3795 const dbEntry = { 3796 name: vendor.name, 3797 address: vendor.address || '', 3798 account_number: vendor.acctNum || '', 3799 phone: vendor.phone || '', 3800 freight: vendor.freight || '', 3801 products: Array.isArray(vendor.products) ? vendor.products : [], 3802 rep_name: (vendor.reps && vendor.reps[0] && vendor.reps[0].name) || '', 3803 rep_email: (vendor.reps && vendor.reps[0] && vendor.reps[0].email) || '', 3804 rep_phone: (vendor.reps && vendor.reps[0] && vendor.reps[0].phone) || '', 3805 website: vendor.website || '', 3806 notes: vendor.notes || '', 3807 status: 'active' 3808 }; 3809 const { data, error } = await sb.from('vendors') 3810 .upsert(dbEntry, { onConflict: 'name' }) 3811 .select('id') 3812 .maybeSingle(); 3813 if (!error && data && data.id) { 3814 vendor.id = data.id; 3815 vendor._fromDb = true; 3816 vendorId = data.id; 3817 } else if (error) { 3818 console.warn('[patch.js] FIX 42 auto-upsert error:', error.message || error); 3819 } 3820 } catch(_) { /* keep placeholder below if even upsert fails */ } 3821 } 3822 3823 if (!vendorId) { 3824 section.innerHTML = 3825 '<div style="font-weight:600;font-size:13px;color:var(--t);margin-bottom:8px;">Documents</div>' 3826 + '<div style="color:var(--t3);font-size:12px;padding:10px;background:var(--d2);border:1px dashed var(--b);border-radius:6px;">' 3827 + 'Save this vendor first, then reopen to upload documents (freight sheets, price lists, install guides, etc.).' 3828 + '</div>'; 3829 return; 3830 } 3831 3832 const typeOpts = DOC_TYPES.map(t => '<option value="'+t+'">'+t+'</option>').join(''); 3833 section.innerHTML = 3834 '<div style="font-weight:600;font-size:13px;color:var(--t);margin-bottom:8px;">Documents</div>' 3835 + '<div style="display:flex;gap:6px;align-items:center;margin-bottom:10px;flex-wrap:wrap;">' 3836 + '<input type="file" id="vdoc-file" style="flex:1;min-width:180px;padding:6px;background:var(--d2);border:1px solid var(--b);border-radius:6px;color:var(--t);font-size:12px;">' 3837 + '<select id="vdoc-type" style="padding:6px 8px;background:var(--d2);border:1px solid var(--b);border-radius:6px;color:var(--t);font-size:12px;">' + typeOpts + '</select>' 3838 + '<input id="vdoc-notes" placeholder="notes (optional)" style="flex:1;min-width:140px;padding:6px 8px;background:var(--d2);border:1px solid var(--b);border-radius:6px;color:var(--t);font-size:12px;">' 3839 + '<button id="vdoc-upload-btn" onclick="vendorDocs_upload(\'' + vend
3839orId + '\')" style="background:var(--gold);color:#fff;border:none;border-radius:6px;padding:7px 14px;font-size:12px;font-weight:600;cursor:pointer;">Upload</button>' 3840 + '</div>' 3841 + '<div id="vdoc-list"></div>'; 3842 3843 window.vendorDocs_render(vendorId); 3844 } 3845 3846 // Observe modal insertion so docs section appears whenever the vendor modal opens 3847 const mo = new MutationObserver(() => { try { injectDocsSection(); } catch(_){} }); 3848 try { mo.observe(document.body, { childList: true, subtree: false }); } catch(_){} 3849 3850 console.log('[patch.js] FIX 21: vendor documents UI wired'); 3851 })(); 3852 3853 // === FIX 41: ð doc-count badges on vendor list cards ===================== 3854 // Joey's request: "when u go to vendor u dont see pdf attached - u have to 3855 // click edit to view it". Surfaces the count of attached vendor_documents 3856 // on each vendor card so reps can see which vendors have freight sheets, 3857 // price lists, install guides, etc. without opening every edit modal. 3858 // Wraps renderVendors so badges paint on every list refresh; exposes 3859 // window.refreshVendorDocBadges so upload/delete handlers can trigger a 3860 // repaint without re-rendering the entire list. 3861 (function vendorDocBadges(){ 3862 if (typeof window === 'undefined' || typeof sb === 'undefined') return; 3863 3864 async function fetchDocCountsByVendor(){ 3865 try { 3866 const { data, error } = await sb.from('vendor_documents') 3867 .select('vendor_id') 3868 .eq('is_archived', false); 3869 if (error || !Array.isArray(data)) return {}; 3870 const counts = {}; 3871 data.forEach(function(row){ 3872 if (!row || !row.vendor_id) return; 3873 counts[row.vendor_id] = (counts[row.vendor_id] || 0) + 1; 3874 }); 3875 return counts; 3876 } catch(_) { return {}; } 3877 } 3878 3879 function paintBadges(counts){ 3880 const list = document.getElementById('vendor-list'); 3881 if (!list || typeof VENDORS === 'undefined' || !Array.isArray(VENDORS)) return; 3882 const cards = list.querySelectorAll(':scope > div'); 3883 cards.forEach(function(card){ 3884 // Each card has an Edit button with onclick="openAddVendorModal(<idx>)" 3885 const editBtn = card.querySelector('button[onclick^="openAddVendorModal"]'); 3886 if (!editBtn) return; 3887 const m = (editBtn.getAttribute('onclick') || '').match(/openAddVendorModal\((\d+)\)/); 3888 if (!m) return; 3889 const idx = parseInt(m[1], 10); 3890 const vendor = VENDORS[idx]; 3891 if (!vendor || !vendor.id) return; 3892 const count = counts[vendor.id] || 0; 3893 // Strip any prior badge so re-renders don't stack duplicates 3894 const prior = card.querySelector('.vdoc-count-badge'); 3895 if (prior) prior.remove(); 3896 if (!count) return; 3897 // Find the vendor name element (first child div with the 16px font-weight 600 style) 3898 const nameDiv = card.querySelector('div[style*="font-size:16px"][style*="font-weight:600"]'); 3899 if (!nameDiv) return; 3900 const badge = document.createElement('span'); 3901 badge.className = 'vdoc-count-badge'; 3902 badge.title = 'Click to view ' + count + ' attached document' + (count === 1 ? '' : 's'); 3903 // FIX 42: badge is a clickable shortcut into the vendor's edit modal, 3904 // where the Documents section lists all attached files with Open 3905 // buttons. Calls openAddVendorModal(idx) â the same handler the 3906 // adjacent "Edit" button uses â so behavior is consistent. 3907 badge.style.cssText = 'background:rgba(139,94,60,.15);color:var(--gold);border:1px solid rgba(139,94,60,.4);border-radius:10px;padding:2px 8px;font-size:10px;font-weight:600;margin-left:8px;display:inline-flex;align-items:center;gap:3px;vertical-align:middle;cursor:pointer;'; 3908 badge.textContent = 'ð ' + count + ' doc' + (count === 1 ? '' : 's'); 3909 badge.addEventListener('click', function(ev){ 3910 ev.stopPropagation(); 3911 ev.preventDefault(); 3912 if (typeof window.openAddVendorModal === 'function') { 3913 window.openAddVendorModal(idx); 3914 } 3915 }); 3916 nameDiv.appendChild(badge); 3917 }); 3918 } 3919 3920 async function refresh(){ 3921 const counts = await fetchDocCountsByVendor(); 3922 paintBadges(counts); 3923 } 3924
3925 // Expose so vendorDocs_upload / vendorDocs_delete (or anything else) can 3926 // trigger a repaint without re-rendering the whole vendor list. 3927 window.refreshVendorDocBadges = refresh; 3928 3929 // Wrap renderVendors. FIX 20 already wrapped it once; we layer another 3930 // wrapper on top â wrapping is composable. 3931 if (typeof window.renderVendors === 'function') { 3932 const _origRV = window.renderVendors; 3933 window.renderVendors = function(){ 3934 const r = _origRV.apply(this, arguments); 3935 // The original may be sync OR a promise; either way, schedule paint 3936 // after the render flushes, plus a second paint to catch slow DB 3937 // merges from FIX 20. 3938 setTimeout(refresh, 250); 3939 setTimeout(refresh, 1200); 3940 return r; 3941 }; 3942 } 3943 3944 console.log('[patch.js] FIX 41: vendor doc-count badges wired'); 3945 })(); 3946 3947 // === FIX 55: Installer documents upload UI (mirrors vendor docs FIX 21/40/42) 3948 // Adds a Documents section to the installer edit modal. Uses Supabase 3949 // storage bucket "installer-docs" and table public.installer_documents. 3950 // Joey wanted to attach driver license scans, COI, photos, etc. to each 3951 // installer record. Same upload + open + delete + auto-upsert pattern as 3952 // the vendor docs flow, ported. 3953 (function installerDocs(){ 3954 if (typeof window === 'undefined' || typeof sb === 'undefined') return; 3955 const BUCKET = 'installer-docs'; 3956 const DOC_TYPES = ['drivers-license','insurance-coi','w9','contract','photo','license-cert','other']; 3957 3958 async function listInstallerDocs(installerId){ 3959 const { data, error } = await sb.from('installer_documents') 3960 .select('*').eq('installer_id', installerId).eq('is_archived', false).order('uploaded_at', { ascending: false }); 3961 if (error) { console.error('[patch.js] FIX 55 list err:', error); return []; } 3962 return data || []; 3963 } 3964 3965 async function uploadInstallerDoc(installerId, file, docType, notes){ 3966 const safe = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); 3967 const uuid = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Date.now()+'-'+Math.random().toString(36).slice(2)); 3968 const path = installerId + '/' + uuid + '-' + safe; 3969 const up = await sb.storage.from(BUCKET).upload(path, file, { contentType: file.type || undefined, upsert: false }); 3970 if (up.error) throw up.error; 3971 let userId = null; 3972 try { const { data: ud } = await sb.auth.getUser(); userId = ud && ud.user ? ud.user.id : null; } catch(_){} 3973 const row = { 3974 installer_id: installerId, file_name: file.name, file_path: path, 3975 doc_type: docType || 'other', mime_type: file.type || null, 3976 size_bytes: file.size || null, notes: notes || null, uploaded_by: userId 3977 }; 3978 const ins = await sb.from('installer_documents').insert(row).select('*').maybeSingle(); 3979 if (ins.error) { 3980 try { await sb.storage.from(BUCKET).remove([path]); } catch(_){} 3981 throw ins.error; 3982 } 3983 return ins.data; 3984 } 3985 3986 async function signedUrlFor(path, seconds){ 3987 const { data, error } = await sb.storage.from(BUCKET).createSignedUrl(path, seconds || 3600); 3988 if (error) throw error; 3989 return data.signedUrl; 3990 } 3991 3992 window.installerDocs_open = async function(docId){ 3993 // FIX 38 pattern: synchronous popup BEFORE the awaits so the user-gesture 3994 // flag is still valid when window.open fires; then redirect to signed URL. 3995 const newTab = window.open('about:blank', '_blank');
3996 try { 3997 const { data: d, error } = await sb.from('installer_documents').select('file_path').eq('id', docId).maybeSingle(); 3998 if (error || !d) throw error || new Error('Not found'); 3999 const url = await signedUrlFor(d.file_path, 3600); 4000 if (newTab && !newTab.closed) { 4001 newTab.location.href = url; 4002 } else { 4003 try { await navigator.clipboard.writeText(url); } catch(_){} 4004 alert('Your browser blocked the popup. The signed link has been copied to your clipboard â paste it into a new tab to view the document.'); 4005 } 4006 } catch(e){ 4007 if (newTab && !newTab.closed) { try { newTab.close(); } catch(_){} } 4008 alert('Could not open document: ' + (e.message||e)); 4009 } 4010 }; 4011 4012 window.installerDocs_delete = async function(docId, installerId){ 4013 if (!confirm('Delete this document?')) return; 4014 try { 4015 const { data: d } = await sb.from('installer_documents').select('file_path').eq('id', docId).maybeSingle(); 4016 if (d && d.file_path) { try { await sb.storage.from(BUCKET).remove([d.file_path]); } catch(_){} } 4017 const { error } = await sb.from('installer_documents').delete().eq('id', docId); 4018 if (error) throw error; 4019 await window.installerDocs_render(installerId); 4020 if (typeof window.refreshInstallerDocBadges === 'function') window.refreshInstallerDocBadges(); 4021 } catch(e){ alert('Could not delete: ' + (e.message||e)); } 4022 }; 4023 4024 window.installerDocs_upload = async function(installerId){ 4025 const fileInput = document.getElementById('idoc-file'); 4026 const typeInput = document.getElementById('idoc-type'); 4027 const notesInput = document.getElementById('idoc-notes'); 4028 if (!fileInput || !fileInput.files || !fileInput.files[0]) { alert('Pick a file first'); return; } 4029 const file = fileInput.files[0]; 4030 if (file.size > 26214400) { alert('File too big (25MB max)'); return; } 4031 const btn = document.getElementById('idoc-upload-btn'); 4032 if (btn) { btn.disabled = true; btn.textContent = 'Uploadingâ¦'; } 4033 try { 4034 await uploadInstallerDoc(installerId, file, typeInput ? typeInput.value : 'other', notesInput ? notesInput.value.trim() : ''); 4035 if (typeof toast === 'function') toast('Document uploaded â', 'ok'); 4036 fileInput.value = ''; if (notesInput) notesInput.value = ''; 4037 await window.installerDocs_render(installerId); 4038 if (typeof window.refreshInstallerDocBadges === 'function') window.refreshInstallerDocBadges(); 4039 } catch(e) { 4040 alert('Upload failed: ' + (e.message || e)); 4041 } finally { 4042 if (btn) { btn.disabled = false; btn.textContent = 'Upload'; } 4043 } 4044 }; 4045 4046 window.installerDocs_render = async function(installerId){ 4047 const host = document.getElementById('idoc-list'); 4048 if (!host) return; 4049 host.innerHTML = '<div style="color:var(--t3);font-size:12px;padding:8px;">Loadingâ¦</div>'; 4050 const docs = await listInstallerDocs(installerId); 4051 if (!docs.length) { host.innerHTML = '<div style="color:var(--t3);font-size:12px;padding:8px;">No documents uploaded yet.</div>'; return; } 4052 function fmtSize(b){ if(!b) return ''; if(b<1024) return b+' B'; if(b<1048576) return (b/1024).toFixed(1)+' KB'; return (b/1048576).toFixed(1)+' MB'; } 4053 function fmtDate(s){ try { return new Date(s).toLocaleDateString(); } catch(_){ return ''; } } 4054 host.innerHTML = docs.map(d => ( 4055 '<div style="display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--b);border-radius:6px;margin-bottom:6px;background:var(--d2);">' 4056 + '<div style="flex:1;min-width:0;">' 4057 + '<div style="font-size:13px;font-weight:600;color:var(--t);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + (d.file_name||'(unnamed)') + '</div>' 4058 + '<div style="font-size:11px;color:var(--t3);margin-top:2px;">' 4059 + '<span style="background:var(--d3);padding:2px 6px;border-radius:4px;margin-right:6px;">' + (d.doc_type||'other') + '</span>' 4060 + fmtDate(d.uploaded_at) + ' · ' + fmtSize(d.size_bytes) 4061 + (d.notes ? ' · ' + d.notes.replace(/</g,'<') : '') 4062 + '</div>' 4063 + '</div>' 4064 + '<button onclick="installerDocs_open(\'' + d.id + '\')" style="background:var(--d3);border:1px solid var(--b);border-radius:6px;padding:6px 10px;font-size:12px;color:var(--t);cursor:pointer;">Open</button>' 4065 + '<button onclick="installerDocs_delete(\'' + d.id + '\',\'' + installerId + '\')" style="background:rgba(198,40,40,.08);border:1px solid rgba(198,40,40,.2);border-radius:6px;padding:6px 10px;font-size:12px;color:#c62828;cursor:pointer;">Delete</button>' 4066 + '</div>' 4067 )).join(''); 4068 }; 4069 4070 async function injectDocsSection(){ 4071 const modal = document.getElementById('installer-modal-overlay'); 4072 if (!modal) return; 4073 if (modal.querySelector('#idoc-section')) return; 4074 const saveBtn = modal.querySelector('button[onclick^="saveInstaller"]'); 4075 const buttonRow = saveBtn ? saveBtn.parentElement : null; 4076 if (!buttonRow) return; 4077 4078 // Synchronous placeholder so the MutationObserver doesn't double-inject. 4079 const section = document.createElement('div'); 4080 section.id = 'idoc-section'; 4081 section.style.cssText = 'margin-top:20px;padding-top:16px;border-top:1px solid var(--b);'; 4082 section.innerHTML = '<div style="font-weight:600;font-size:13px;color:var(--t);margin-bottom:8px;">Documents</div>' 4083 + '<div style="color:var(--t3);font-size:12px;padding:10px;">Loading documentsâ¦</div>'; 4084 buttonRow.parentNode.insertBefore(section, buttonRow); 4085 4086 // Resolve installer id from saveInstaller(idx) â INSTALLERS[idx]._id. 4087 const oc = saveBtn.getAttribute('onclick') || ''; 4088 const m = oc.match(/saveInstaller\((\d+|null)\)/); 4089 const editIdx = m ? (m[1] === 'null' ? null : parseInt(m[1],10)) : null; 4090 const inst = (editIdx !== null && typeof INSTALLERS !== 'undefined') ? INSTALLERS[editIdx] : null; 4091 const installerId = inst && inst._id ? inst._id : null; 4092 4093 if (!installerId) { 4094 section.innerHTML = 4095 '<div style="font-weight:600;font-size:13px;color:var(--t);margin-bottom:8px;">Documents</div>' 4096 + '<div style="color:var(--t3);font-size:12px;padding:10px;background:var(--d2);border:1px dashed var(--b);border-radius:6px;">' 4097 + 'Save this installer first, then reopen to upload documents (driver license, COI, photos, etc.).' 4098 + '</div>'; 4099 return; 4100 } 4101 4102 const typeOpts = DOC_TYPES.map(t => '<option value="'+t+'">'+t+'</option>').join(''); 4103 section.innerHTML = 4104 '<div style="font-weight:600;font-size:13px;color:var(--t);margin-bottom:8px;">Documents</div>' 4105 + '<div style="display:flex;gap:6px;align-items:center;margin-bottom:10px;flex-wrap:wrap;">' 4106 + '<input type="file" id="idoc-file" style="flex:1;min-width:180px;padding:6px;background:var(--d2);border:1px solid var(--b);border-radius:6px;color:var(--t);font-size:12px;">' 4107 + '<select id="idoc-type" style="padding:6px 8px;background:var(--d2);border:1px solid var(--b);border-radius:6px;color:var(--t);font-size:12px;">' + typeOpts + '</select>' 4108 + '<input id="idoc-notes" placeholder="notes (optional)" style="flex:1;min-width:140px;padding:6px 8px;background:var(--d2);border:1px solid var(--b);border-radius:6px;color:var(--t);font-size:12px;">' 4109 + '<button id="idoc-upload-btn" onclick="installerDocs_upload(\'' + installerId + '\')" style="background:var(--gold);color:#fff;border:none;border-radius:6px;padding:7px 14px;font-size:12px;font-weight:600;cursor:pointer;">Upload</button>' 4110 + '</div>' 4111 + '<div id="idoc-list"></div>'; 4112 window.installerDocs_render(installerId); 4113 } 4114 4115 const mo = new MutationObserver(() => { try { injectDocsSection(); } catch(_){} }); 4116 try { mo.observe(document.body, { childList: true, subtree: false }); } catch(_){} 4117 4118 console.log('[patch.js] FIX 55: installer documents UI wired'); 4119 })(); 4120 4121 // === FIX 56: ð doc-count badges on installer list cards (mirrors FIX 41). 4122 (function installerDocBadges(){ 4123 if (typeof window === 'undefined' || typeof sb === 'undefined') return; 4124 4125 async function fetchDocCountsByInstaller(){
4126 try { 4127 const { data, error } = await sb.from('installer_documents') 4128 .select('installer_id') 4129 .eq('is_archived', false); 4130 if (error || !Array.isArray(data)) return {}; 4131 const counts = {}; 4132 data.forEach(function(row){ 4133 if (!row || !row.installer_id) return; 4134 counts[row.installer_id] = (counts[row.installer_id] || 0) + 1; 4135 }); 4136 return counts; 4137 } catch(_) { return {}; } 4138 } 4139 4140 function paintBadges(counts){ 4141 const list = document.getElementById('installer-list'); 4142 if (!list || typeof INSTALLERS === 'undefined' || !Array.isArray(INSTALLERS)) return; 4143 const cards = list.querySelectorAll(':scope > div'); 4144 cards.forEach(function(card){ 4145 const editBtn = card.querySelector('button[onclick^="openAddInstallerModal"]'); 4146 if (!editBtn) return; 4147 const m = (editBtn.getAttribute('onclick') || '').match(/openAddInstallerModal\((\d+)\)/); 4148 if (!m) return; 4149 const idx = parseInt(m[1], 10); 4150 const inst = INSTALLERS[idx]; 4151 if (!inst || !inst._id) return; 4152 const count = counts[inst._id] || 0; 4153 const prior = card.querySelector('.idoc-count-badge'); 4154 if (prior) prior.remove(); 4155 if (!count) return; 4156 const nameDiv = card.querySelector('div[style*="font-size:16px"][style*="font-weight:600"], div[style*="font-size: 16px"][style*="font-weight: 600"]'); 4157 if (!nameDiv) return; 4158 const badge = document.createElement('span'); 4159 badge.className = 'idoc-count-badge'; 4160 badge.title = 'Click to view ' + count + ' attached document' + (count === 1 ? '' : 's'); 4161 badge.style.cssText = 'background:rgba(139,94,60,.15);color:var(--gold);border:1px solid rgba(139,94,60,.4);border-radius:10px;padding:2px 8px;font-size:10px;font-weight:600;margin-left:8px;display:inline-flex;align-items:center;gap:3px;vertical-align:middle;cursor:pointer;'; 4162 badge.textContent = 'ð ' + count + ' doc' + (count === 1 ? '' : 's'); 4163 badge.addEventListener('click', function(ev){ 4164 ev.stopPropagation(); 4165 ev.preventDefault(); 4166 if (typeof window.openAddInstallerModal === 'function') { 4167 window.openAddInstallerModal(idx); 4168 } 4169 }); 4170 nameDiv.appendChild(badge); 4171 }); 4172 } 4173 4174 async function refresh(){ 4175 const counts = await fetchDocCountsByInstaller(); 4176 paintBadges(counts); 4177 } 4178 4179 window.refreshInstallerDocBadges = refresh; 4180 4181 if (typeof window.renderInstallers === 'function') { 4182 const _origRI = window.renderInstallers; 4183 window.renderInstallers = function(){ 4184 const r = _origRI.apply(this, arguments); 4185 setTimeout(refresh, 250); 4186 setTimeout(refresh, 1200); 4187 return r; 4188 }; 4189 } 4190 4191 console.log('[patch.js] FIX 56: installer doc-count badges wired'); 4192 })(); 4193 4194 // === FIX 22: Hide retired vendors from the UI ============================ 4195 // Joey's request: "delete vendor DuChateau". 4196 // DuChateau lives in the hardcoded VENDORS seed array (src/17-vendors.js) 4197 // and not in the vendors DB table, so DB-level delete isn't possible. 4198 // This fix splices the named vendor(s) out of the in-memory VENDORS array 4199 // on page load (and again after a short delay to cover late DB merge from 4200 // FIX 20). Add future retired vendor names to HIDDEN_VENDOR_NAMES. 4201 (function hideRetiredVendors(){ 4202 const HIDDEN_VENDOR_NAMES = ['DuChateau']; 4203 function purge(){ 4204 try { 4205 if (typeof VENDORS === 'undefined' || !Array.isArray(VENDORS)) return 0; 4206 const hidden = HIDDEN_VENDOR_NAMES.map(h => h.toLowerCase()); 4207 let removed = 0; 4208 for (let i = VENDORS.length - 1; i >= 0; i--) { 4209 const n = (VENDORS[i] && VENDORS[i].name || '').toLowerCase(); 4210 if (n && hidden.includes(n)) { VENDORS.splice(i, 1); removed++; } 4211 } 4212 return removed; 4213 } catch(_) { return 0; } 4214 } 4215 // Immediate + delayed retry (in case VENDORS loads after patch.js) 4216 purge(); 4217 setTimeout(purge, 200); 4218 setTimeout(purge, 1500); 4219 // Also purge after any renderVendors call so DB merges can't reintroduce 4220 if (typeof window !== 'undefined' && typeof window.renderVendors === 'function') { 4221 const _orig = window.renderVendors; 4222 window.renderVendors = async function(){ 4223 const ret = await _orig.apply(this, arguments); 4224 purge(); 4225 return ret; 4226 }; 4227 }
4228 console.log('[patch.js] FIX 22: hidden vendor filter applied (' + HIDDEN_VENDOR_NAMES.join(', ') + ')'); 4229 })(); 4230 4231 // === FIX 23 / FIX 71: Custom Places Autocomplete on address inputs ======= 4232 // Custom REST-based autocomplete (FIX 71). Rebuilt from scratch â the old 4233 // Google Maps JS SDK approach (FIX 23 â FIX 69 â FIX 70) was unreliable on 4234 // this Cloud project: the new <gmp-place-autocomplete> web component's 4235 // gmp-select event fired inconsistently, the legacy ctor left inputs unable 4236 // to take keystrokes, and load failures bricked the field with no recovery. 4237 // 4238 // FIX 71 hits the Places REST API directly (no SDK) and renders our own 4239 // dropdown. Cleaner code, fewer moving parts, predictable behaviour: 4240 // 1. user types â debounced fetch to places:autocomplete 4241 // 2. predictions render in a portal-styled dropdown anchored to the input 4242 // 3. click â fetch place details â fill street/city/state/zip 4243 // 4. keyboard nav (ââ Enter Esc) + click-outside-to-close 4244 // 4245 // Targeted inputs: 4246 // #lf-addr (Lead form â also fills #lf-ci, #lf-st, #lf-zip) 4247 // #quote-job-address #sf-address #vnd-f-address #dp-stop-addr 4248 // #np-address #cc-address (single-field â get full formatted address) 4249 (function placesAutocomplete(){ 4250 // FIX 71: REST-based Places autocomplete. Bypasses the Google Maps JS SDK 4251 // entirely â direct HTTPS calls to places.googleapis.com with our own 4252 // dropdown. No SDK = no flaky <gmp-place-autocomplete> events, no 4253 // deprecated legacy ctor side-effects, predictable across all browsers. 4254 4255 // HTTP-referrer-restricted to portal.jamailhardwoods.com â safe to ship. 4256 const API_KEY = 'AIzaSyC0PXuiOAedcvwuM7HRr3bmZ29pgNz-AJ4'; 4257 4258 // Inputs to attach to. Lead form has separate city/state/zip siblings; 4259 // others are single-field (get the full formatted address). 4260 const TARGETS = [ 4261 { sel:'#lf-addr', city:'#lf-ci', state:'#lf-st', zip:'#lf-zip' }, 4262 { sel:'#quote-job-address' }, 4263 { sel:'#sf-address' }, 4264 { sel:'#vnd-f-address' }, 4265 { sel:'#dp-stop-addr' }, 4266 { sel:'#np-address' }, 4267 { sel:'#cc-address' }, 4268 ]; 4269 4270 // ââ Inject dropdown styles once ââââââââââââââââââââââââââââââââââââ 4271 if (!document.getElementById('jh-pac-styles')) { 4272 const css = document.createElement('style'); 4273 css.id = 'jh-pac-styles'; 4274 css.textContent = ` 4275 .jh-pac-dropdown { 4276 position: fixed; 4277 z-index: 999999; 4278 background: #ffffff; 4279 border: 1px solid #E8E6E3; 4280 border-radius: 8px; 4281 box-shadow: 0 8px 24px rgba(0,0,0,0.18); 4282 font-family: Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; 4283 font-size: 13px; 4284 color: #2a2a2a; 4285 max-height: 320px; 4286 overflow-y: auto; 4287 padding: 4px 0; 4288 } 4289 .jh-pac-item { 4290 padding: 9px 14px; 4291 cursor: pointer; 4292 line-height: 1.35; 4293 border-left: 3px solid transparent; 4294 } 4295 .jh-pac-item.active, 4296 .jh-pac-item:hover { 4297 background: #faf6ee; 4298 border-left-color: #B8860B; 4299 } 4300 .jh-pac-item .jh-pac-main { color: #2a2a2a; font-weight: 500; } 4301 .jh-pac-item .jh-pac-sec { color: #888; font-size: 11.5px;
4301 margin-top: 1px; } 4302 .jh-pac-empty { 4303 padding: 12px 14px; 4304 color: #888; 4305 font-size: 12.5px; 4306 text-align: center; 4307 } 4308 .jh-pac-attribution { 4309 padding: 6px 14px; 4310 color: #aaa; 4311 font-size: 10px; 4312 letter-spacing: 0.5px; 4313 text-transform: uppercase; 4314 border-top: 1px solid #f0eee9; 4315 text-align: right; 4316 } 4317 `; 4318 (document.head || document.documentElement).appendChild(css); 4319 } 4320 4321 // ââ Single shared dropdown element âââââââââââââââââââââââââââââââââ 4322 let dropdown = null; 4323 let activeInput = null; 4324 let activeTarget = null; // { sel, city?, state?, zip? } 4325 let predictions = []; // array of { placeId, mainText, secondaryText } 4326 let activeIdx = -1; 4327 let sessionToken = null; // refreshes per autocomplete session for billing 4328 let abortCtl = null; 4329 4330 function ensureDropdown(){ 4331 if (dropdown) return dropdown; 4332 dropdown = document.createElement('div'); 4333 dropdown.className = 'jh-pac-dropdown'; 4334 dropdown.style.display = 'none'; 4335 document.body.appendChild(dropdown); 4336 // Click on a suggestion 4337 dropdown.addEventListener('mousedown', (e) => { 4338 const item = e.target.closest('.jh-pac-item'); 4339 if (!item) return; 4340 e.preventDefault(); 4341 const idx = parseInt(item.dataset.idx, 10); 4342 if (!isNaN(idx)) selectPrediction(idx); 4343 }); 4344 return dropdown; 4345 } 4346 4347 function newSessionToken(){ 4348 // RFC4122-ish token. Used by Places API for session-based billing. 4349 sessionToken = 'jh-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10); 4350 return sessionToken; 4351 } 4352 4353 function positionDropdown(input){ 4354 if (!dropdown) return; 4355 const r = input.getBoundingClientRect(); 4356 dropdown.style.left = r.left + 'px'; 4357 dropdown.style.top = (r.bottom + 2) + 'px'; 4358 dropdown.style.width = r.width + 'px'; 4359 } 4360 4361 function hideDropdown(){ 4362 if (dropdown) dropdown.style.display = 'none'; 4363 activeInput = null; 4364 activeTarget = null; 4365 predictions = []; 4366 activeIdx = -1; 4367 if (abortCtl) { try { abortCtl.abort(); } catch(_){} abortCtl = null; } 4368 } 4369 4370 function renderDropdown(){ 4371 const dd = ensureDropdown(); 4372 if (!predictions.length) { 4373 dd.innerHTML = '<div class="jh-pac-empty">No matching addresses</div>' 4374 + '<div class="jh-pac-attribution">Powered by Google</div>'; 4375 } else { 4376 dd.innerHTML = predictions.map((p, i) => { 4377 const cls = i === activeIdx ? 'jh-pac-item active' : 'jh-pac-item'; 4378 const main = (p.mainText || '').replace(/[<>]/g, ''); 4379 const sec = (p.secondaryText || '').replace(/[<>]/g, ''); 4380 return '<div class="' + cls + '" data-idx="' + i + '">' 4381 + '<div class="jh-pac-main">' + main + '</div>' 4382 + (sec ? '<div class="jh-pac-sec">' + sec + '</div>' : '') 4383 + '</div>'; 4384 }).join('') + '<div class="jh-pac-attribution">Powered by Google</div>'; 4385 } 4386 dd.style.display = 'block'; 4387 if (activeInput) positionDropdown(activeInput); 4388 } 4389 4390 // ââ Places REST calls ââââââââââââââââââââââââââââââââââââââââââââââ 4391 async function fetchPredictions(query){ 4392 if (!query || query.length < 3) { predictions = []; renderDropdown(); return; } 4393 if (!sessionToken) newSessionToken(); 4394 if (abortCtl) { try { abortCtl.abort(); } catch(_){} } 4395 abortCtl = new AbortController(); 4396 try { 4397 const res = await fetch('https://places.googleapis.com/v1/places:autocomplete', { 4398 method: 'POST', 4399 signal: abortCtl.signal, 4400 headers: { 4401 'Content-Type': 'application/json', 4402 'X-Goog-Api-Key': API_KEY, 4403 'X-Goog-FieldMask': 'suggestions.placePrediction.placeId,suggestions.placePrediction.text,suggestions.placePrediction.structuredFormat', 4404 }, 4405 body: JSON.stringify({ 4406 input: query,
4407 includedRegionCodes: ['us'], 4408 sessionToken: sessionToken, 4409 }), 4410 }); 4411 if (!res.ok) { 4412 const txt = await res.text(); 4413 console.warn('[patch.js] FIX 71 autocomplete: API ' + res.status, txt); 4414 // FIX 72: surface the actual cause to the rep instead of silent 4415 // dropdown failure. 403 almost always = referrer allowlist miss. 4416 if (res.status === 403 && typeof toast === 'function' && !window._jhPacToast403) { 4417 window._jhPacToast403 = true; 4418 toast('Address suggestions blocked by Google API key restriction. Loading correct domain... (refresh if it persists)', 'err'); 4419 } 4420 predictions = []; 4421 renderDropdown(); 4422 return; 4423 } 4424 const json = await res.json(); 4425 predictions = (json.suggestions || []) 4426 .map(s => s.placePrediction) 4427 .filter(Boolean) 4428 .map(p => ({ 4429 placeId: p.placeId, 4430 mainText: p.structuredFormat?.mainText?.text || p.text?.text || '', 4431 secondaryText: p.structuredFormat?.secondaryText?.text || '', 4432 })); 4433 activeIdx = predictions.length > 0 ? 0 : -1; 4434 renderDropdown(); 4435 } catch (e) { 4436 if (e.name !== 'AbortError') console.warn('[patch.js] FIX 71 autocomplete fetch failed:', e.message); 4437 } 4438 } 4439 4440 async function fetchPlaceDetails(placeId){ 4441 try { 4442 const res = await fetch('https://places.googleapis.com/v1/places/' + encodeURIComponent(placeId) 4443 + '?sessionToken=' + encodeURIComponent(sessionToken || ''), { 4444 headers: { 4445 'X-Goog-Api-Key': API_KEY, 4446 'X-Goog-FieldMask': 'formattedAddress,addressComponents', 4447 }, 4448 }); 4449 if (!res.ok) { 4450 console.warn('[patch.js] FIX 71 details: API ' + res.status, await res.text()); 4451 return null; 4452 } 4453 return await res.json(); 4454 } catch (e) { 4455 console.warn('[patch.js] FIX 71 details fetch failed:', e.message); 4456 return null; 4457 } finally { 4458 // session ends after a details call per Google's billing model 4459 sessionToken = null; 4460 } 4461 } 4462 4463 function parseComponents(comps){ 4464 const out = { street:'', city:'', state:'', zip:'' }; 4465 let sn = '', rt = ''; 4466 (comps || []).forEach(c => { 4467 const t = c.types || []; 4468 const longText = c.longText || ''; 4469 const shortText = c.shortText || ''; 4470 if (t.includes('street_number')) sn = longText; 4471 if (t.includes('route')) rt = longText; 4472 if (t.includes('locality')) out.city = longText; 4473 if (!out.city && t.includes('sublocality')) out.city = longText; 4474 if (!out.city && t.includes('postal_town')) out.city = longText; 4475 if (t.includes('administrative_area_level_1')) out.state = shortText; 4476 if (t.includes('postal_code')) out.zip = longText; 4477 }); 4478 out.street = [sn, rt].filter(Boolean).join(' '); 4479 return out; 4480 } 4481 4482 function setField(selector, value){ 4483 if (!selector) return; 4484 const el = document.querySelector(selector); 4485 if (!el) return; 4486 el.value = value || ''; 4487 try { el.dispatchEvent(new Event('input', { bubbles:true })); } catch(_){} 4488 try { el.dispatchEvent(new Event('change', { bubbles:true })); } catch(_){} 4489 } 4490 4491 async function selectPrediction(idx){ 4492 const pred = predictions[idx]; 4493 if (!pred || !activeInput) { hideDropdown(); return; } 4494 const target = activeTarget; 4495 const input = activeInput; 4496 hideDropdown(); 4497 const details = await fetchPlaceDetails(pred.placeId); 4498 if (!details) { 4499 // Fallback: paste the prediction text into the input 4500 input.value = [pred.mainText, pred.secondaryText].filter(Boolean).join(', '); 4501 try { input.dispatchEvent(new Event('input', { bubbles:true })); } catch(_){} 4502 try { input.dispatchEvent(new Event('change', { bubbles:true })); } catch(_){} 4503 return; 4504 } 4505 const parsed = parseComponents(details.addressComponents); 4506 const formatted = details.formattedAddress || ''; 4507 const hasSiblings = !!(target && (target.city || target.state || target.zip)); 4508 input.value = hasSiblings 4509 ? (parsed.street || formatted || input.value) 4510 : (formatted || parsed.street || input.value);
4511 try { input.dispatchEvent(new Event('input', { bubbles:true })); } catch(_){} 4512 try { input.dispatchEvent(new Event('change', { bubbles:true })); } catch(_){} 4513 if (hasSiblings) { 4514 setField(target.city, parsed.city); 4515 setField(target.state, parsed.state); 4516 setField(target.zip, parsed.zip); 4517 } 4518 } 4519 4520 // ââ Debounce helper âââââââââââââââââââââââââââââââââââââââââââââââââ 4521 function debounce(fn, ms){ 4522 let t; 4523 return function(){ 4524 const args = arguments, ctx = this; 4525 clearTimeout(t); 4526 t = setTimeout(() => fn.apply(ctx, args), ms); 4527 }; 4528 } 4529 4530 const onTypeDebounced = debounce((value) => fetchPredictions(value), 220); 4531 4532 // ââ Wire up an input âââââââââââââââââââââââââââââââââââââââââââââââ 4533 function attach(input, target){ 4534 if (!input || input._jhPacAttached) return; 4535 input._jhPacAttached = true; 4536 4537 input.setAttribute('autocomplete', 'off'); 4538 4539 input.addEventListener('focus', () => { 4540 activeInput = input; 4541 activeTarget = target; 4542 if (!sessionToken) newSessionToken(); 4543 if (input.value && input.value.length >= 3) { 4544 fetchPredictions(input.value); 4545 } 4546 }); 4547 4548 input.addEventListener('input', (e) => { 4549 activeInput = input; 4550 activeTarget = target; 4551 onTypeDebounced(e.target.value); 4552 }); 4553 4554 input.addEventListener('keydown', (e) => { 4555 if (!dropdown || dropdown.style.display === 'none' || !predictions.length) return; 4556 if (e.key === 'ArrowDown') { 4557 e.preventDefault(); 4558 activeIdx = Math.min(predictions.length - 1, activeIdx + 1); 4559 renderDropdown(); 4560 } else if (e.key === 'ArrowUp') { 4561 e.preventDefault(); 4562 activeIdx = Math.max(0, activeIdx - 1); 4563 renderDropdown(); 4564 } else if (e.key === 'Enter') { 4565 if (activeIdx >= 0) { 4566 e.preventDefault(); 4567 selectPrediction(activeIdx); 4568 } 4569 } else if (e.key === 'Escape') { 4570 hideDropdown(); 4571 } 4572 }); 4573 4574 input.addEventListener('blur', () => { 4575 // Delay so a click on a suggestion lands first 4576 setTimeout(() => { 4577 if (document.activeElement !== input) hideDropdown(); 4578 }, 200); 4579 }); 4580 } 4581 4582 function attachAll(){ 4583 TARGETS.forEach(t => { 4584 document.querySelectorAll(t.sel).forEach(el => attach(el, t)); 4585 }); 4586 } 4587 4588 // Reposition / hide dropdown on scroll/resize 4589 window.addEventListener('scroll', () => { if (activeInput) positionDropdown(activeInput); }, true); 4590 window.addEventListener('resize', () => { if (activeInput) positionDropdown(activeInput); }); 4591 4592 // Click outside the dropdown closes it 4593 document.addEventListener('mousedown', (e) => { 4594 if (!dropdown || dropdown.style.display === 'none') return; 4595 if (e.target === activeInput || dropdown.contains(e.target)) return; 4596 hideDropdown(); 4597 }); 4598 4599 // Watch DOM for new inputs (modals open / close) 4600 try { 4601 new MutationObserver(() => attachAll()).observe(document.body, { childList:true, subtree:true }); 4602 } catch(_) {} 4603 4604 // Initial pass 4605 attachAll(); 4606 4607 console.log('[patch.js] FIX 71: REST-based Places autocomplete wired (' + TARGETS.length + ' input selectors)'); 4608 })(); 4609 4610 // ============================================================================ 4611 // FIX 24: Quote Change Requests â badge on list + panel in Quote Builder 4612 // 4613 // Client "Request Changes" from quote-view.html writes to quote_change_requests. 4614 // Before this fix, nothing in the portal surfaced them: rows would sit in the 4615 // table with status='pending' and no rep would ever see them. This fix: 4616 // 4617 // (a) Fetches pending-count per quote_id and paints a red "ð N pending" 4618 // badge on each affected quote row after renderQuotesTab finishes. 4619 // (b) Monkey-patches renderQuoteBuilder so that after the overlay is drawn, 4620 // a "Change Requests" section is appended at the bottom using the 4621 // existing renderChangeRequests() + markChangeRequestReviewed() funcs 4622 // already defined in src/59-workflow-automation.js. 4623 // (c) Wraps markChangeRequestReviewed so that after a request is applied 4624 // or declined, the builder panel + row badges refresh automatically. 4625 // ============================================================================ 4626 (function changeRequestsUI(){ 4627 if (typeof sb === 'undefined') { 4628 // sb not ready yet â retry once the app has booted. 4629 document.addEventListener('DOMContentLoaded', function(){ setTimeout(changeRequestsUI, 500); }, { once: true }); 4630 return; 4631 } 4632 4633 // ---- helpers ---------------------------------------------------------- 4634 async function fetchPendingCountsByQuote(){
4635 try { 4636 const { data, error } = await sb.from('quote_change_requests').select('quote_id,status').eq('status','pending'); 4637 if (error || !data) return {}; 4638 const counts = {}; 4639 data.forEach(function(r){ 4640 const id = r.quote_id; 4641 counts[id] = (counts[id] || 0) + 1; 4642 }); 4643 return counts; 4644 } catch (_) { return {}; } 4645 } 4646 4647 function paintBadgesOnQuoteRows(counts){ 4648 const list = document.getElementById('quotes-list'); 4649 if (!list) return; 4650 const rows = list.querySelectorAll('.quote-row'); 4651 rows.forEach(function(row){ 4652 const qid = parseInt(row.getAttribute('data-id'), 10); 4653 if (!qid) return; 4654 // Remove any prior badge so we don't stack duplicates on re-render 4655 const prior = row.querySelector('.cr-pending-badge'); 4656 if (prior) prior.remove(); 4657 const n = counts[qid] || 0; 4658 if (!n) return; 4659 const badge = document.createElement('div'); 4660 badge.className = 'cr-pending-badge'; 4661 badge.title = n + ' pending change request' + (n === 1 ? '' : 's') + ' from client'; 4662 badge.style.cssText = 'display:inline-flex;align-items:center;gap:4px;background:#ef4444;color:#fff;font-size:10px;font-weight:700;padding:3px 8px;border-radius:10px;margin-left:6px;flex-shrink:0;white-space:nowrap;'; 4663 badge.innerHTML = 'ð ' + n + ' pending'; 4664 // Insert inside the first inner flex container (the header row) so it floats near the quote number. 4665 const flex = row.querySelector('div[style*="display:flex"]'); 4666 (flex || row).appendChild(badge); 4667 }); 4668 } 4669 4670 async function refreshChangeRequestBadges(){ 4671 const counts = await fetchPendingCountsByQuote(); 4672 paintBadgesOnQuoteRows(counts); 4673 } 4674 4675 // Expose so other code (and manual debugging) can trigger a repaint 4676 window.refreshChangeRequestBadges = refreshChangeRequestBadges; 4677 4678 // ---- (a) Badges on quote list after renderQuotesTab ------------------- 4679 if (typeof window.renderQuotesTab === 'function') { 4680 const _origRenderQuotesTab = window.renderQuotesTab; 4681 window.renderQuotesTab = async function(){ 4682 const r = await _origRenderQuotesTab.apply(this, arguments); 4683 // loadAndRenderQuotes runs async inside â wait a tick then paint. 4684 setTimeout(refreshChangeRequestBadges, 400); 4685 setTimeout(refreshChangeRequestBadges, 1200); // second paint after slower loads 4686 return r; 4687 }; 4688 } 4689 // Also repaint any time a filter pill is clicked inside the quotes tab 4690 document.addEventListener('click', function(ev){ 4691 const pill = ev.target && ev.target.closest && ev.target.closest('#tab-quotes .filter-pill'); 4692 if (pill) setTimeout(refreshChangeRequestBadges, 400); 4693 }, true); 4694 4695 // ---- (b) Change Requests panel inside Quote Builder ------------------- 4696 async function injectChangeRequestsPanel(quoteId){ 4697 if (!quoteId) return; 4698 const overlay = document.getElementById('ov-quote-builder'); 4699 if (!overlay) return; 4700 // Don't double-inject 4701 let panel = overlay.querySelector('#cr-panel-wrap'); 4702 if (!panel) { 4703 panel = document.createElement('div'); 4704 panel.id = 'cr-panel-wrap'; 4705 panel.style.cssText = 'margin:24px 20px;padding:16px;background:var(--d2);border:1px solid var(--b);border-radius:8px;'; 4706 panel.innerHTML = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">' + 4707 '<div style="color:var(--t);font-weight:700;font-size:14px">ð Client Change Requests</div>' + 4708 '<button id="cr-panel-refresh" style="background:transparent;border:1px solid var(--b);color:var(--t3);font-size:11px;padding:4px 10px;border-radius:4px;cursor:pointer">â» Refresh</button>' + 4709 '</div>' + 4710 '<div id="cr-panel-body" style="color:var(--t3);font-size:13px">Loadingâ¦</div>'; 4711 // Append near the bottom of the overlay's inner content 4712 const inner = overlay.querySelector('div[style*="max-width"]') || overlay; 4713 inner.appendChild(panel); 4714 const refreshBtn = panel.querySelector('#cr-panel-refresh'); 4715 if (refreshBtn) refreshBtn.addEventListener('click', function(){ renderPanelBody(quoteId); }); 4716 } 4717 await renderPanelBody(quoteId); 4718 } 4719 4720 async function renderPanelBody(quoteId){ 4721 const body = document.getElementById('cr-panel-body'); 4722 if (!body) return; 4723 body.innerHTML = '<div style="color:var(--t3);font-size:13px">Loadingâ¦</div>';
4724 try { 4725 if (typeof window.renderChangeRequests !== 'function') { 4726 body.innerHTML = '<div style="color:#ef4444;font-size:13px">renderChangeRequests() not available in this build.</div>'; 4727 return; 4728 } 4729 const html = await window.renderChangeRequests(quoteId); 4730 body.innerHTML = html || '<div style="color:var(--t3);font-size:13px;padding:8px">No change requests</div>'; 4731 } catch (e) { 4732 body.innerHTML = '<div style="color:#ef4444;font-size:13px">Failed to load change requests: ' + (e && e.message ? e.message : e) + '</div>'; 4733 } 4734 } 4735 4736 if (typeof window.renderQuoteBuilder === 'function') { 4737 const _origRenderQuoteBuilder = window.renderQuoteBuilder; 4738 window.renderQuoteBuilder = async function(existingQuote){ 4739 const r = await _origRenderQuoteBuilder.apply(this, arguments); 4740 const qid = existingQuote && existingQuote.id ? existingQuote.id : null; 4741 if (qid) setTimeout(function(){ injectChangeRequestsPanel(qid); }, 250); 4742 return r; 4743 }; 4744 } 4745 4746 // ---- (c) Wrap markChangeRequestReviewed to refresh UI after review ---- 4747 if (typeof window.markChangeRequestReviewed === 'function') { 4748 const _origMark = window.markChangeRequestReviewed; 4749 window.markChangeRequestReviewed = async function(requestId, status){ 4750 const r = await _origMark.apply(this, arguments); 4751 // Re-render the panel in the open Quote Builder (if any) 4752 try { 4753 const overlay = document.getElementById('ov-quote-builder'); 4754 if (overlay) { 4755 // Infer quoteId from the quote-number input that renderQuoteBuilder draws 4756 const qNumInput = overlay.querySelector('#quote-number'); 4757 const qNum = qNumInput && qNumInput.value; 4758 if (qNum && typeof sb !== 'undefined') { 4759 const { data } = await sb.from('quotes').select('id').eq('quote_number', qNum).limit(1); 4760 const qid = data && data[0] && data[0].id; 4761 if (qid) await renderPanelBody(qid); 4762 } 4763 } 4764 } catch(_){} 4765 // And repaint list badges since pending count dropped 4766 refreshChangeRequestBadges(); 4767 return r; 4768 }; 4769 } 4770 4771 console.log('[patch.js] FIX 24: Change Requests UI wired (badges + panel + auto-refresh)'); 4772 })(); 4773 4774})(); 4775 4776 4777// === FIX 27: Bucket/gallon pricing for adhesives, finishes, polys === 4778// Replaces the default per-sqft math for non-sqft-priced products. 4779// For these items: line_total = ceil(sqft / coverage) Ã unit_price 4780(function bucketPricing() { 4781 var BUCKET_KEYWORDS = ['adhesive','glue','finish','stain','sealer','seal','poly','gallon','bucket','bona','lacquer','primer']; 4782 // (Cowork fix 2026-05-06) Categories that are sold by sqft/carton and must 4783 // NEVER be treated as bucket-priced, even if their name/category text 4784 // happens to contain a substring like "finish" (e.g. "Pre-finished Hardwood"). 4785 // Without this guard, hardwood line items had their per-sqft price divided 4786 // by the carton coverage (e.g. $7.25/sf â $0.41/sf displayed) and the badge 4787 // label flickered between "carton" and "gallon" every 600 ms while the 4788 // setInterval poller fought the original render. 4789 var FLOORING_GUARD = /(hardwood|laminate|vinyl|lvp|lvt|plank|tile|carpet|engineered|moulding|molding|stair|tread)/i; 4790 function isBucketProduct(p) { 4791 if (!p) return false; 4792 var cat = (p.category || '').toLowerCase(); 4793 var name = (p.product_name || '').toLowerCase(); 4794 // Hard exclude any flooring/material category â it's sold by sqft, period. 4795 if (FLOORING_GUARD.test(cat)) return false; 4796 for (var i = 0; i < BUCKET_KEYWORDS.length; i++) { 4797 var k = BUCKET_KEYWORDS[i]; 4798 if (cat.indexOf(k) !== -1 || name.indexOf(k) !== -1) return true; 4799 } 4800 return false; 4801 } 4802 function findCatalogMatch(sku, desc) { 4803 if (typeof _productCatalogCache === 'undefined' || !_productCatalogCache) return null; 4804 if (sku) { 4805 var bySku = _productCatalogCache.find(function(p){ return p.sku && p.sku === sku; }); 4806 if (bySku) return bySku; 4807 } 4808 if (desc) { 4809 var byName = _productCatalogCache.find(function(p){ return p.product_name === desc; }); 4810 if (byName) return byName; 4811 } 4812 return null; 4813 } 4814 function unitLabel(p) { 4815 var cat = (p.category || '').toLowerCase(); 4816 var name = (p.product_name || '').toLowerCase(); 4817 if (cat.indexOf('adhesive') !== -1 || cat.indexOf('glue') !== -1 || name.indexOf('bona') !== -1 || name.indexOf('adhesive') !== -1 || name.indexOf('glue') !== -1) return 'bucket'; 4818 if (cat.indexOf('finish') !== -1 || cat.indexOf('seal') !== -1 || cat.indexOf('stain') !== -1 || cat.indexOf('poly') !== -1 || name.indexOf('gallon') !== -1) return 'gallon'; 4819 return 'unit'; 4820 } 4821 function tick() { 4822 if (typeof _productCatalogCache === 'undefined' || !_productCatalogCache) return; 4823 var items = document.querySelectorAll('.quote-item'); 4824 if (!items || items.length === 0) return; 4825 var anyChanged = false;
4826 items.forEach(function(itemDiv) { 4827 var skuEl = itemDiv.querySelector('.item-sku'); 4828 var descEl = itemDiv.querySelector('.item-description'); 4829 var sqftEl = itemDiv.querySelector('.item-sqft'); 4830 var totalEl = itemDiv.querySelector('.item-line-total'); 4831 var sellSfEl = itemDiv.querySelector('.item-sell-sf'); 4832 var costSfEl = itemDiv.querySelector('.item-cost-sf'); 4833 var matSellEl = itemDiv.querySelector('.item-material-sell'); 4834 var matCostEl = itemDiv.querySelector('.item-material-cost'); 4835 if (!sqftEl || !totalEl) return; 4836 var sku = (skuEl && skuEl.value) || ''; 4837 var desc = (descEl && descEl.value) || ''; 4838 var match = findCatalogMatch(sku, desc); 4839 if (!match || !isBucketProduct(match)) return; 4840 var coverage = parseFloat(match.coverage) || 0; 4841 var unitCost = parseFloat(match.unit_cost) || 0; 4842 var unitPrice = parseFloat(match.selling_price) || 0; 4843 var sqft = parseFloat(sqftEl.value) || 0; 4844 if (coverage <= 0 || sqft <= 0 || unitPrice <= 0) return; 4845 var units = Math.ceil(sqft / coverage); 4846 var newSell = +(units * unitPrice).toFixed(2); 4847 var newCost = +(units * unitCost).toFixed(2); 4848 // Write back 4849 if (matSellEl && Math.abs((parseFloat(matSellEl.value) || 0) - newSell) > 0.005) { matSellEl.value = newSell.toFixed(2); anyChanged = true; } 4850 if (matCostEl && Math.abs((parseFloat(matCostEl.value) || 0) - newCost) > 0.005) { matCostEl.value = newCost.toFixed(2); anyChanged = true; } 4851 if (totalEl && Math.abs((parseFloat(totalEl.value) || 0) - newSell) > 0.005) { totalEl.value = newSell.toFixed(2); anyChanged = true; } 4852 // Per-sqft equivalents (so downstream reporting stays consistent) 4853 if (sellSfEl) sellSfEl.value = (newSell / sqft).toFixed(4); 4854 if (costSfEl) costSfEl.value = (newCost / sqft).toFixed(4); 4855 // Mark line + relabel badge from "carton" â "bucket"/"gallon" 4856 itemDiv.setAttribute('data-bucket-priced', 'true'); 4857 itemDiv.setAttribute('data-bucket-units', String(units)); 4858 var label = unitLabel(match); 4859 var sqftParent = sqftEl.parentNode; 4860 var badge = sqftParent && sqftParent.querySelector('.carton-badge'); 4861 if (badge) { 4862 var plural = units !== 1 ? 's' : ''; 4863 var newBadgeText = '\u{1F4E6} ' + units + ' ' + label + plural + ' = ' + (units * coverage).toFixed(2) + ' sf (' + coverage + ' sf/' + label + ')'; 4864 if (badge.textContent !== newBadgeText) badge.textContent = newBadgeText; 4865 } 4866 }); 4867 // If we changed anything, recalculate the quote header totals 4868 if (anyChanged && typeof updateQuoteTotals === 'function') { 4869 try { updateQuoteTotals(); } catch (e) {} 4870 } 4871 } 4872 setInterval(tick, 600); 4873 console.log('[patch.js] FIX 27: Bucket/gallon pricing polling started'); 4874})(); 4875 4876// === FIX 28: Add Void + Delete buttons to invoice detail modal, hide soft-deleted invoices === 4877// Joey requested ability to delete or void invoices from the UI. Schema review confirmed: 4878// - `deleted_at` (timestamptz) is the existing soft-delete column convention 4879// - status='Void' is the existing convention 4880// - No void_reason / voided_at columns â we append the reason to `notes` 4881// This fix: 4882// (a) wraps `loadInvoicesOps` so the list query filters out deleted_at IS NOT NULL 4883// (b) wraps `renderInvoiceDetail` to inject Void + Delete buttons into the modal footer 4884// and wire up handlers that confirm with the user (extra-strict if balance > 0) 4885// (Re-add of original FIX 22 work that was overwritten by a later deploy.) 4886(function patchInvoiceVoidDelete(){ 4887 if (typeof loadInvoicesOps !== 'function' || typeof renderInvoiceDetail !== 'function') { 4888 console.warn('[patch.js] FIX 28 skipped: invoice functions not loaded'); 4889 return; 4890 } 4891 4892 // (a) Hide soft-deleted invoices from the list 4893 const _origLoadInvoicesOps = loadInvoicesOps; 4894 window.loadInvoicesOps = async function(){ 4895 const all = await _origLoadInvoicesOps.apply(this, arguments); 4896 return (all || []).filter(inv => !inv.deleted_at); 4897 }; 4898 4899 // Helper: void an invoice (status='Void', append reason to notes) 4900 async function voidInvoiceFromUI(invoice){ 4901 if (!invoice || !invoice.id) return; 4902 if (invoice.status === 'Void') { 4903 if (typeof toast === 'function') toast('Invoice is already Void', 'err'); 4904 return; 4905 } 4906 const reason = prompt( 4907 'Void invoice ' + (invoice.invoice_number || '#' + invoice.id) + '?\n\n' + 4908 'Voiding marks the invoice as cancelled but keeps it visible in reports.\n' + 4909 'Enter a reason (required):' 4910 ); 4911 if (reason === null) return; // cancelled 4912 const trimmed = (reason || '').trim(); 4913 if (!trimmed) { 4914 if (typeof toast === 'function') toast('Void cancelled â reason required', 'err'); 4915 return; 4916 } 4917 // Extra confirm if money has been paid 4918 const paid = Number(invoice.amount_paid || 0); 4919 if (paid > 0) { 4920 const ok = confirm(
4921 'WARNING: $' + paid.toLocaleString('en-US',{minimumFractionDigits:2}) + 4922 ' has already been paid on this invoice.\n\n' + 4923 'Voiding will NOT refund the customer. Proceed anyway?' 4924 ); 4925 if (!ok) return; 4926 } 4927 const stamp = new Date().toISOString().slice(0,10); 4928 const userTag = (typeof prof !== 'undefined' && prof && prof.email) ? prof.email : 'admin'; 4929 const newNote = 4930 '[' + stamp + '] VOIDED by ' + userTag + ' â Reason: ' + trimmed + 4931 (invoice.notes ? '\n\n' + invoice.notes : ''); 4932 const { error } = await sb.from('invoices') 4933 .update({ status: 'Void', notes: newNote, updated_at: new Date().toISOString() }) 4934 .eq('id', invoice.id); 4935 if (error) { 4936 if (typeof toast === 'function') toast('Void failed: ' + error.message, 'err'); 4937 console.error('[patch.js] FIX 28 void error:', error); 4938 return; 4939 } 4940 if (typeof logAudit === 'function') { 4941 try { await logAudit('invoice_void', 'invoice', invoice.id, { reason: trimmed, prev_status: invoice.status }); } catch(e){} 4942 } 4943 if (typeof toast === 'function') toast('Invoice voided', 'ok'); 4944 if (typeof closeOv === 'function') closeOv('ov-invoice-detail'); 4945 if (typeof loadAndRenderInvoices === 'function') { 4946 const cur = (typeof currentFilter !== 'undefined' && currentFilter) ? currentFilter : 'all'; 4947 await loadAndRenderInvoices(cur); 4948 } 4949 } 4950 4951 // Helper: soft-delete an invoice (sets deleted_at) 4952 async function deleteInvoiceFromUI(invoice){ 4953 if (!invoice || !invoice.id) return; 4954 const paid = Number(invoice.amount_paid || 0); 4955 const total = Number(invoice.total_sale || 0); 4956 4957 // Check for related records that should block delete 4958 const [{ count: woCount }, { count: payCount }] = await Promise.all([ 4959 sb.from('work_orders').select('id', { count:'exact', head:true }).eq('invoice_id', invoice.id), 4960 sb.from('invoice_payments').select('id', { count:'exact', head:true }).eq('invoice_id', invoice.id) 4961 ]); 4962 if ((woCount || 0) > 0) { 4963 if (typeof toast === 'function') toast('Cannot delete: invoice has ' + woCount + ' work order(s). Void instead.', 'err'); 4964 return; 4965 } 4966 if ((payCount || 0) > 0 || paid > 0) { 4967 if (typeof toast === 'function') toast('Cannot delete: invoice has payments recorded. Void instead.', 'err'); 4968 return; 4969 } 4970 4971 const ok = confirm( 4972 'DELETE invoice ' + (invoice.invoice_number || '#' + invoice.id) + '?\n\n' + 4973 'Total: $' + total.toLocaleString('en-US',{minimumFractionDigits:2}) + '\n' + 4974 'Paid: $' + paid.toLocaleString('en-US',{minimumFractionDigits:2}) + '\n\n' + 4975 'This is a SOFT delete â the row is hidden but recoverable from the database.\n' + 4976 'For permanent removal, ask Claude to hard-delete from Supabase.\n\n' + 4977 'Continue?' 4978 ); 4979 if (!ok) return; 4980 4981 const userTag = (typeof prof !== 'undefined' && prof && prof.email) ? prof.email : 'admin'; 4982 const stamp = new Date().toISOString(); 4983 const { error } = await sb.from('invoices') 4984 .update({ deleted_at: stamp, updated_at: stamp, 4985 notes: '[' + stamp.slice(0,10) + '] SOFT-DELETED by ' + userTag + (invoice.notes ? '\n\n' + invoice.notes : '') }) 4986 .eq('id', invoice.id); 4987 if (error) { 4988 if (typeof toast === 'function') toast('Delete failed: ' + error.message, 'err'); 4989 console.error('[patch.js] FIX 28 delete error:', error); 4990 return; 4991 } 4992 if (typeof logAudit === 'function') { 4993 try { await logAudit('invoice_delete', 'invoice', invoice.id, { soft: true, total: total }); } catch(e){} 4994 } 4995 if (typeof toast === 'function') toast('Invoice deleted', 'ok'); 4996 if (typeof closeOv === 'function') closeOv('ov-invoice-detail'); 4997 if (typeof loadAndRenderInvoices === 'function') { 4998 const cur = (typeof currentFilter !== 'undefined' && currentFilter) ? currentFilter : 'all'; 4999 await loadAndRenderInvoices(cur); 5000 } 5001 } 5002 5003 // Expose helpers globally so they can be called from inline onclick if needed 5004 window.voidInvoiceFromUI = voidInvoiceFromUI; 5005 window.deleteInvoiceFromUI = deleteInvoiceFromUI; 5006 5007 // (b) Wrap renderInvoiceDetail to inject the buttons after the modal renders 5008 const _origRenderInvoiceDetail = renderInvoiceDetail; 5009 window.renderInvoiceDetail = async function(invoice){ 5010 await _origRenderInvoiceDetail.apply(this, arguments); 5011 try { 5012 const closeBtn = document.getElementById('close-invoice-detail-btn'); 5013 if (!closeBtn || !closeBtn.parentNode) return; 5014 const footer = closeBtn.parentNode; 5015 // Avoid duplicate buttons if the modal is re-rendered 5016 if (document.getElementById('btn-void-invoice') || document.getElementById('btn-delete-invoice')) return; 5017 5018 const isVoid = invoice && invoice.status === 'Void'; 5019 5020 const voidBtn = document.createElement('button'); 5021 voidBtn.id = 'btn-void-invoice'; 5022 voidBtn.textContent = isVoid ? 'ð« Voided' : 'ð« Void Invoice'; 5023 voidBtn.disabled = !!isVoid; 5024 voidBtn.style.cssText = 'padding:10px 20px;background:' + (isVoid ? '#444' : '#7a4a00') + 5025 ';color:#fff;border:1px solid var(--b);border-radius:6px;cursor:' + (isVoid ? 'default' : 'pointer') + 5026 ';font-weight:bold;opacity:' + (isVoid ? '0.6' : '1') + ';'; 5027 if (!isVoid) voidBtn.addEventListener('click', () => voidInvoiceFromUI(invoice)); 5028 5029 const delBtn = document.createElement('button'); 5030 delBtn.id = 'btn-delete-invoice'; 5031 delBtn.textContent = 'ð Delete'; 5032 delBtn.style.cssText = 'padding:10px 20px;background:#7a1a1a;color:#fff;border:1px solid #a33;border-radius:6px;cursor:pointer;font-weight:bold;'; 5033 delBtn.addEventListener('click', () => deleteInvoiceFromUI(invoice)); 5034 5035 // Insert before the Close button so the destructive actions sit at the right end 5036 footer.insertBefore(voidBtn, closeBtn); 5037 footer.insertBefore(delBtn, closeBtn); 5038 } catch (e) { 5039 console.error('[patch.js] FIX 28 inject error:', e); 5040 } 5041 }; 5042 5043 console.log('[patch.js] FIX 28: invoice Void + Delete buttons wired; deleted_at filter active'); 5044})(); 5045 5046// === FIX 29: Lead Board / Job Board / Schedule / Dash Pro / Floorzap export salvage === 5047// Some inline onclick handlers (Lead Board, Job Board, etc.) target functions defined 5048// in src/51..56. If app.js is built or deployed before those exports were wired
5048into 5049// build.js's exportNames list, the inline handlers throw "X is not defined". 5050// patch.js loads AFTER app.js, so we can re-attach anything visible at script scope. 5051// (Re-add of original FIX 23 work that was overwritten by a later deploy.) 5052(function fix29ExportSalvage(){ 5053 try { 5054 // Names defined in src/51..56 that inline onclick handlers depend on. 5055 var names = [ 5056 // 55-kanban.js 5057 'renderLeadKanban','renderJobKanban','openKanbanBoard','showKanbanPanel', 5058 // 54-dashboard-pro.js 5059 'renderConversionsFunnel','renderKpiSidebar','renderPaymentProgress', 5060 'initDashboardTabs','switchDashTab','renderTeamRankings','initDashPro', 5061 // 56-wo-invoice-pro.js 5062 'getWoProgress','renderWoProgressBar','invoiceStatusBadge', 5063 'initQuickActionBar','initWoInvoicePro', 5064 // 51-schedule.js 5065 'renderScheduleTab','loadScheduleData','openScheduleForm', 5066 'saveScheduleEntry','deleteScheduleEntry','schedSwitchView', 5067 'schedApplyFilters','schedPrevMonth','schedNextMonth','schedShowDay', 5068 'schedSendNotification','schedCheckConflicts','schedPrefillContractor', 5069 'schedSearchLeads','schedSelectLead','schedAddQuickContractor' 5070 ]; 5071 var fixed = 0, missing = []; 5072 for (var i = 0; i < names.length; i++) { 5073 var n = names[i]; 5074 try { 5075 // Use a Function constructor probe to read from the global lexical scope 5076 // (top-level `function foo(){}` declarations are visible there even when 5077 // an `if (typeof foo)` check inside a nested IIFE returned undefined). 5078 var probe = new Function('try{return typeof ' + n + ' !== "undefined" ? ' + n + ' : undefined}catch(e){return undefined}'); 5079 var fn = probe(); 5080 if (typeof fn === 'function') { 5081 window[n] = fn; 5082 fixed++; 5083 } else if (typeof window[n] !== 'function') { 5084 missing.push(n); 5085 } 5086 } catch (e) { missing.push(n); } 5087 } 5088 console.log('[patch.js] FIX 29: salvaged ' + fixed + '/' + names.length + ' exports' + 5089 (missing.length ? ' (still missing: ' + missing.join(',') + ')' : '')); 5090 5091 // Fallback for openKanbanBoard if it's still missing â minimum viable 5092 // implementation so the Lead Board / Job Board nav items don't throw. 5093 if (typeof window.openKanbanBoard !== 'function') { 5094 window.openKanbanBoard = function(type){ 5095 try { 5096 if (typeof sideNav === 'function') sideNav('quotes'); 5097 else if (typeof goTab === 'function') goTab('quotes'); 5098 var opsTab = document.getElementById('tab-quotes'); 5099 if (!opsTab) { 5100 if (typeof toast === 'function') toast('Open Quotes tab first', 'err'); 5101 return; 5102 } 5103 var wrap = document.getElementById('kanban-wrap'); 5104 if (!wrap) { 5105 wrap = document.createElement('div'); 5106 wrap.id = 'kanban-wrap'; 5107 wrap.style.cssText = 'margin-bottom:20px;'; 5108 wrap.innerHTML = 5109 '<div style="display:flex;gap:2px;margin-bottom:12px;background:var(--d3);border-radius:10px;padding:3px;">' + 5110 '<button id="kanban-lead-btn" onclick="showKanbanPanel(\'lead\')" style="flex:1;padding:8px 12px;border:none;border-radius:8px;cursor:pointer;font-size:12px;font-weight:600;font-family:inherit;background:var(--gold);color:#1a1008;">Lead Board</button>' + 5111 '<button id="kanban-job-btn" onclick="showKanbanPanel(\'job\')" style="flex:1;padding:8px 12px;border:none;border-radius:8px;cursor:pointer;font-size:12px;font-weight:600;font-family:inherit;background:transparent;color:var(--t3);">Job Board</button>' + 5112 '</div>' + 5113 '<div id="kanban-lead-board"></div>' + 5114 '<div id="kanban-job-board" style="display:none;"></div>'; 5115 opsTab.insertBefore(wrap, opsTab.firstChild); 5116 } 5117 wrap.style.display = ''; 5118 if (typeof window.showKanbanPanel === 'function') window.showKanbanPanel(type || 'lead'); 5119 else if (typeof toast === 'function') toast('Kanban renderer missing â redeploy app.js', 'err'); 5120 } catch (e) { 5121 console.error('[patch.js] FIX 29 openKanbanBoard fallback error:', e); 5122 if (typeof toast === 'function') toast('Lead/Job Board failed: ' + e.message, 'err'); 5123 } 5124 }; 5125 console.log('[patch.js] FIX 29: installed minimal openKanbanBoard fallback (renderers still required)'); 5126 } 5127 5128 // Verify the inline-onclick handler for Lead/Job Board nav items will resolve. 5129 var navOk = (typeof window.openKanbanBoard === 'function'); 5130 if (!navOk) { 5131 console.warn('[patch.js] FIX 29: Lead/Job Board nav items will still fail â openKanbanBoard not on window'); 5132 } 5133 } catch (e) { 5134 console.error('[patch.js] FIX 29 error:', e); 5135 } 5136})(); 5137 5138// === FIX 30: Editable Client Info on Invoice Detail modal === 5139// Joey: "when u have invoice, why cant u make edits and click save? 5140// Invoice JH-100355 i want to add contact phone and address but not able to do this" 5141// renderInvoiceDetail in src/22-operations.js renders Client Information as read-only 5142// <div> tags with no save mechanism. This patch wraps renderInvoiceDetail and after it 5143// draws, injects an "âï¸ Edit" button into the Client Information panel. Edit swaps the 5144// grid for <input> fields (Company, Customer, Contact, Phone, Email, Job Name, 5145// Job Address, Service Type). Save persists via Supabase and re-renders the modal. 5146(function () { 5147 try { 5148 var EDIT_FIELDS = [ 5149 { key: 'company', label: 'Company', type: 'text' }, 5150 { key: 'customer', label: 'Customer', type: 'text' }, 5151 { key: 'contact_name', label: 'Contact', type: 'text' }, 5152 { key: 'contact_phone', label: 'Phone', type: 'tel' }, 5153 { key: 'contact_email', label: 'Email', type: 'email' }, 5154 { key: 'job_name', label: 'Job Name', type: 'text' }, 5155 { key: 'job_address', label: 'Job Address', type: 'text' }, 5156 { key: 'service_type', label: 'Service Type', type: 'text' } 5157 ]; 5158 5159 function escAttr(v) { 5160 return String(v == null ? '' : v).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>'); 5161 } 5162 5163 function findClientInfoBlock() { 5164 var ov = document.getElementById('inv-detail-overlay'); 5165 if (!ov) return null; 5166 var h3s = ov.querySelectorAll('h3'); 5167 for (var i = 0; i < h3s.length; i++) { 5168 if ((h3s[i].textContent || '').trim().toLowerCase().indexOf('client information') === 0) { 5169 return h3s[i].parentElement; 5170 } 5171 } 5172 return null; 5173 } 5174 5175 function injectEditButton(invoiceId) { 5176 var panel = findClientInfoBlock(); 5177 if (!panel) return; 5178 if (panel.querySelector('[data-fix30-edit-btn]')) return; 5179 var h3 = panel.querySelector('h3'); 5180 if (!h3) return; 5181 h3.style.display = 'flex'; 5182 h3.style.justifyContent = 'space-between'; 5183 h3.style.alignItems = 'center'; 5184 var btn = document.createElement('button'); 5185 btn.setAttribute('data-fix30-edit-btn', '1'); 5186 btn.type = 'button'; 5187 btn.innerHTML = 'âï¸ Edit'; 5188 btn.style.cssText = 'padding:4px 10px;background:var(--gold);
5188color:#1a1008;border:none;border-radius:4px;cursor:pointer;font-weight:bold;font-size:11px;'; 5189 btn.addEventListener('click', function () { switchToEditMode(panel, invoiceId); }); 5190 h3.appendChild(btn); 5191 } 5192 5193 function switchToEditMode(panel, invoiceId) { 5194 (async function () { 5195 var invoice = null; 5196 try { 5197 var sb = window.sb; 5198 if (sb && invoiceId) { 5199 var resp = await sb.from('invoices').select('*').eq('id', invoiceId).single(); 5200 invoice = resp && resp.data; 5201 } 5202 } catch (err) { 5203 console.warn('[FIX 30] could not load invoice from DB:', err); 5204 } 5205 if (!invoice) { 5206 invoice = {}; 5207 var labels = panel.querySelectorAll('label'); 5208 labels.forEach(function (lbl) { 5209 var nx = lbl.nextElementSibling; 5210 var txt = (lbl.textContent || '').replace(':', '').trim().toLowerCase(); 5211 var val = nx ? (nx.textContent || '').trim() : ''; 5212 if (txt === 'company') invoice.company = val; 5213 else if (txt === 'customer') invoice.customer = val; 5214 else if (txt === 'contact') invoice.contact_name = val; 5215 else if (txt === 'phone') invoice.contact_phone = val; 5216 else if (txt === 'job name') invoice.job_name = val; 5217 else if (txt === 'job address') invoice.job_address = val; 5218 else if (txt === 'service type') invoice.service_type = val; 5219 }); 5220 } 5221 renderEditForm(panel, invoiceId, invoice); 5222 })(); 5223 } 5224 5225 function renderEditForm(panel, invoiceId, invoice) { 5226 var h3 = panel.querySelector('h3'); 5227 var oldBtn = panel.querySelector('[data-fix30-edit-btn]'); 5228 if (oldBtn) oldBtn.remove(); 5229 var children = Array.from(panel.children); 5230 for (var i = 0; i < children.length; i++) { 5231 if (children[i] !== h3 && children[i].tagName === 'DIV') { 5232 children[i].remove(); 5233 } 5234 } 5235 var form = document.createElement('div'); 5236 form.setAttribute('data-fix30-edit-form', '1'); 5237 form.style.cssText = 'display:grid;grid-template-columns:auto 1fr;gap:10px;align-items:center;'; 5238 var inputStyle = 'width:100%;padding:6px 10px;background:var(--d1);border:1px solid var(--b);border-radius:4px;color:var(--t);font-size:13px;'; 5239 var html = ''; 5240 EDIT_FIELDS.forEach(function (f) { 5241 var val = invoice[f.key]; 5242 html += '<label style="color:var(--t3);font-size:12px;">' + f.label + ':</label>'; 5243 html += '<input data-fix30-field="' + f.key + '" type="' + f.type + '" value="' + escAttr(val || '') + '" style="' + inputStyle + '">'; 5244 }); 5245 form.innerHTML = html; 5246 panel.appendChild(form); 5247 var actions = document.createElement('div'); 5248 actions.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;margin-top:12px;'; 5249 actions.innerHTML = 5250 '<button data-fix30-cancel type="button" style="padding:8px 14px;background:var(--d2);color:var(--t);border:1px solid var(--b);border-radius:4px;cursor:pointer;font-size:12px;">Cancel</button>' + 5251 '<button data-fix30-save type="button" style="padding:8px 14px;background:#1a5a2f;color:#fff;border:none;border-radius:4px;cursor:pointer;font-weight:bold;font-size:12px;">ð¾ Save</button>'; 5252 panel.appendChild(actions); 5253 panel.querySelector('[data-fix30-cancel]').addEventListener('click', function () { 5254 if (typeof window.renderInvoiceDetail === 'function') window.renderInvoiceDetail(invoiceId); 5255 }); 5256 panel.querySelector('[data-fix30-save]').addEventListener('click', function () { 5257 saveInvoiceClientInfo(panel, invoiceId); 5258 }); 5259 } 5260 5261 async function saveInvoiceClientInfo(panel, invoiceId) { 5262 var saveBtn = panel.querySelector('[data-fix30-save]'); 5263 if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = 'â³ Saving...'; } 5264 try { 5265 var update = {}; 5266 EDIT_FIELDS.forEach(function (f) { 5267 var input = panel.querySelector('[data-fix30-field="' + f.key + '"]'); 5268 if (input) { 5269 var v = input.value.trim(); 5270 update[f.key] = v === '' ? null : v; 5271 } 5272 }); 5273 var sb = window.sb; 5274 if (!sb) throw new Error('Supabase client (sb) missing'); 5275 var resp = await sb.from('invoices').update(update).eq('id', invoiceId).select().single(); 5276 if (resp.error) throw resp.error; 5277 try { 5278 if (typeof window.logAudit === 'function') { 5279 window.logAudit('invoice', invoiceId, 'updated_client_info', { fields: Object.keys(update) }); 5280 } 5281 } catch (_) {} 5282 if (typeof window.toast === 'function') window.toast('â Client info saved', 'ok'); 5283 if (typeof window.renderInvoiceDetail === 'function') window.renderInvoiceDetail(invoiceId); 5284 } catch (e) { 5285 console.error('[FIX 30] save failed:', e); 5286 if (typeof window.toast === 'function') window.toast('Save failed: ' + (e.message || e), 'err'); 5287 if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = 'ð¾ Save'; } 5288 } 5289 } 5290 5291 function wrapRender() { 5292 if (typeof window.renderInvoiceDetail !== 'function') return false;
5293 if (window.renderInvoiceDetail.__fix30Wrapped) return true; 5294 var orig = window.renderInvoiceDetail; 5295 var wrapped = async function (invoiceId) { 5296 var result = await orig.apply(this, arguments); 5297 setTimeout(function () { injectEditButton(invoiceId); }, 50); 5298 return result; 5299 }; 5300 wrapped.__fix30Wrapped = true; 5301 window.renderInvoiceDetail = wrapped; 5302 return true; 5303 } 5304 5305 if (!wrapRender()) { 5306 var attempts = 0; 5307 var iv = setInterval(function () { 5308 attempts++; 5309 if (wrapRender() || attempts > 40) { 5310 clearInterval(iv); 5311 if (attempts > 40) console.warn('[FIX 30] gave up waiting for renderInvoiceDetail'); 5312 } 5313 }, 250); 5314 } 5315 5316 console.log('[patch.js] FIX 30: Editable Client Info on invoice detail modal â installed'); 5317 } catch (e) { 5318 console.error('[patch.js] FIX 30 error:', e); 5319 } 5320})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.