1(function(){ 2 3// -- SUPABASE CONFIG --------------------------------------------------- 4// Paste your Supabase URL and anon key here when you have them 5var SUPA_URL = 'https://fgkilooomlozhwfnvjze.supabase.co'; 6var SUPA_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZna2lsb29vbWxvemh3Zm52anplIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODA3NTc0NTIsImV4cCI6MjA5NjMzMzQ1Mn0.owQk8Vy3Vcs8n8c0sI0fXQYmjpAy14hev8lDt4g5iZE'; 7var supa = null; 8var supaReady = false; 9 10function initSupabase(){ 11 try{ 12 if(SUPA_URL && SUPA_KEY && window.supabase){ 13 supa = window.supabase.createClient(SUPA_URL, SUPA_KEY, { 14 auth: { 15 detectSessionInUrl: true, 16 persistSession: true, 17 autoRefreshToken: true, 18 flowType: 'pkce' 19 } 20 }); 21 supaReady = true; 22 return true; 23 } 24 }catch(e){ console.log('Supabase init error:', e); } 25 return false; 26} 27 28// -- AUTH HEADERS HELPER ------------------------------------------ 29// Always use the live session JWT for Supabase REST calls so RLS 30// policies (which check auth.role() = 'authenticated') pass correctly. 31// Falls back to anon key only when no session exists (should not happen 32// in normal use once auth is live). 33async function getAuthHeaders(extra) { 34 var token = SUPA_KEY; // fallback 35 if(supaReady && supa) { 36 try { 37 var sd = await supa.auth.getSession(); 38 if(sd && sd.data && sd.data.session && sd.data.session.access_token) { 39 token = sd.data.session.access_token; 40 } 41 } catch(e) { /* use fallback */ } 42 } 43 var h = { 44 'apikey': SUPA_KEY, 45 'Authorization': 'Bearer ' + token, 46 'Content-Type': 'application/json' 47 }; 48 if(extra) { Object.keys(extra).forEach(function(k){ h[k] = extra[k]; }); } 49 return h; 50} 51 52// Session-aware headers for our own /api/* endpoints that require a logged-in user. 53// Carries the Supabase session token so the server can verify the caller; falls back to the anon 54// key (which the server rejects) when there's no session. 55async function apiHeaders(){ 56 var token = null; 57 try{ var sd = await supa.auth.getSession(); if(sd && sd.data && sd.data.session) token = sd.data.session.access_token; }catch(e){} 58 return { 'Content-Type':'application/json', 'Authorization':'Bearer ' + (token || SUPA_KEY) }; 59} 60// POST to one of our own /api endpoints with the session token attached. Returns the fetch 61// promise, so it works whether the caller awaits it or fires-and-forgets with .catch(). 62function postApi(path, bodyObj){ 63 return apiHeaders().then(function(h){ return fetch(path, { method:'POST', headers:h, body: JSON.stringify(bodyObj) }); }); 64} 65 66var C=[],N=[],F=[],D=[],TX=[],TM=[],A=[],curDet=null,curFilter='all',curPage='dashboard'; 67var CAMP=[],ENR=[],SENDLOG=[],DOCS=[]; 68var CH=[]; // tx_changes: audit trail of field changes applied from scanned documents 69var RS=[]; // reminder_settings: how many days before each deadline type to remind (cron reads these) 70var RL=[]; // reminder_log: which reminders the cron has already sent (used to hide sent personal reminders) 71var IHOA=[], IPROP=[], IUNIT=[], ILED=[]; // Investments: HOA accounts, properties, units, money ledger 72var IPAYEE=[]; // Approved payees/senders â the editable expense whitelist for email ingestion 73var ISCAN=[]; // inv_scan_log: heartbeat of each rental scan (cron or button) 74var INVREC=[]; // inv_recurring: monthly recurring expense/income templates (fixed auto-post, variable confirm) 75var DSHEET=[]; // deal_sheets: saved Seller Net Sheets (one per client) 76var LOIS=[]; // loi_letters: saved commercial Letters of Intent 77var LISTINGS=[]; // listings: canonical marketable-property records (one entry -> site/social/drip outputs) 78var CPROS=[]; // commercial_prospects: candidate commercial properties scouted for a client (tenant/buyer rep) 79var NC=[]; // nc_communities: new-construction builder communities (from builder agent emails + manual) 80var NC_BUILDERS=[]; // builder-sender list (loaded from settings key 'nc_builders') 81var ILOAN=[], ILPAY=[]; // Hard money: loans made, and payments received 82var curSort='last'; // 'last' or 'first' 83var selectedContacts = new Set();
84// TM = Team Members: [{id, first, last, role, email, phone, color, calLink}] 85 86// -- SAVE / LOAD ------------------------------------------------------- 87function sv(){ 88 try{ 89 localStorage.setItem('crm_c',JSON.stringify(C)); 90 localStorage.setItem('crm_n',JSON.stringify(N)); 91 localStorage.setItem('crm_f',JSON.stringify(F)); 92 localStorage.setItem('crm_d',JSON.stringify(D)); 93 localStorage.setItem('crm_tx',JSON.stringify(TX)); 94 localStorage.setItem('crm_tm',JSON.stringify(TM)); 95 localStorage.setItem('crm_camp',JSON.stringify(CAMP)); 96 localStorage.setItem('crm_enr',JSON.stringify(ENR)); 97 localStorage.setItem('crm_sendlog',JSON.stringify(SENDLOG)); 98 localStorage.setItem('crm_a',JSON.stringify(A)); 99 localStorage.setItem('crm_ch',JSON.stringify(CH)); 100 }catch(e){} 101} 102 103// Supabase sync functions 104// Known columns per table â strips unknown fields before sending to Supabase 105var DB_COLS = { 106 contacts: ['id','first','last','type','phone','email','property','stage','price','notes','added','closeDate','assignedTo','emails','phones','addresses','whatsapp','closedAt','lang'], 107 notes: ['id','contactId','transactionId','text','date','category','textEs'], 108 followups: ['id','contactId','transactionId','label','date','pri','done','assignedTo'], 109 deadlines: ['id','contactId','transactionId','type','date','assignedTo','time','stepKey','owner'], 110 transactions: ['id','contactId','type','category','address','price','mlsNum','lender','titleCo','notes','status','steps','details', 111 'contractDate','closingDate','earnestDate','dueDiligDate','financingDate','appraisalDate', 112 'listCommissionPct','buyerCommissionPct','listCommissionAmt','buyerCommissionAmt', 113 'dismissedRisks','docChecklist','lastAddendumNo','lastAddendumDate', 114 'financingType', 115 'added','soldTerms','concessions','buyerAgent','buyerAgentBrokerage'], 116 campaigns: null, 117 enrollments: null, 118 send_log: null, 119 gmail_tokens: null, 120 documents: ['id','contact_id','transaction_id','file_path','file_name','doc_type','summary','mime_type','size','created_at'], 121 tx_changes: ['id','transactionId','contactId','field','oldValue','newValue','docType','addendumNo','effectiveDate','documentId','appliedAt'], 122 reminder_settings: ['id','deadlineType','daysBefore','enabled','updatedAt','subjectEn','bodyEn','subjectEs','bodyEs'], 123 team: ['id','first','last','role','email','phone','color','calLink'], 124 cal_events: ['id','title','date','time','endTime','type','memberId','contactId','notes'], 125 inv_hoa: ['id','name','account_number','dues_amount','dues_frequency','due_day','notes'], 126 inv_properties: ['id','name','address','hoa_id','purchase_price','purchase_date','mortgage_lender','mortgage_balance','mortgage_payment','mortgage_due_day','notes'], 127 inv_units: ['id','property_id','label','rent_amount','rent_due_day','mgmt_fee','passthrough_amount','passthrough_label','tenant_name','lease_start','lease_end','status','notes'], 128 inv_ledger: ['id','date','property_id','unit_id','hoa_id','direction','category','amount','payee','method','source','email_ref','message_id','received_date','due_date','notes'], 129 inv_payees: ['id','name','match','category','kind','property_id','active','notes'], 130 deal_sheets: ['id','contact_id','name','data','updated_at'], 131 loi_letters: ['id','contact_id','name','data','updated_at'], 132 listings: ['id','contact_id','name','data','updated_at'], 133 commercial_prospects: ['id','contact_id','name','data','updated_at'], 134 nc_communities: ['id','builder','name','data','updated_at'], 135 inv_loans: ['id','borrower','address','notes','principal','interest_rate','term_months','start_date','end_date','first_payment_date','monthly_payment','status'], 136 inv_loan_payments: ['id','loan_id','date','amount','note'],
137 inv_recurring: ['id','property_id','unit_id','category','payee','amount','day_of_month','variable','active','notes'] 138}; 139function stripForDB(table, row){ 140 var cols = DB_COLS[table]; 141 if(!cols) return row; 142 var out = {}; 143 cols.forEach(function(k){ if(row[k] !== undefined) out[k] = row[k]; }); 144 return out; 145} 146 147// ---- DOCUMENTS: storage + metadata helpers ---- 148var DOC_BUCKET = 'documents'; 149 150function docStoragePath(file, meta){ 151 var base = (file && file.name) ? file.name : 'document'; 152 var safeName = base.replace(/[^A-Za-z0-9._-]/g, '_'); 153 var anchor = (meta && meta.contact_id) ? ('c' + meta.contact_id) 154 : (meta && meta.transaction_id) ? ('t' + meta.transaction_id) 155 : 'unfiled'; 156 return anchor + '/' + Date.now() + '_' + safeName; 157} 158 159async function uploadDocFile(file, path){ 160 var headers = await getAuthHeaders({'Content-Type': (file.type || 'application/octet-stream'), 'x-upsert': 'true'}); 161 var resp = await fetch(SUPA_URL + '/storage/v1/object/' + DOC_BUCKET + '/' + path, { 162 method: 'POST', headers: headers, body: file 163 }); 164 if(!resp.ok){ var t = await resp.text(); throw new Error('Upload failed: ' + t.substring(0, 200)); } 165 return true; 166} 167 168async function getDocUrl(path){ 169 var headers = await getAuthHeaders(); 170 var resp = await fetch(SUPA_URL + '/storage/v1/object/sign/' + DOC_BUCKET + '/' + path, { 171 method: 'POST', headers: headers, body: JSON.stringify({expiresIn: 3600}) 172 }); 173 var data = await resp.json(); 174 if(data.error || !data.signedURL){ throw new Error('Could not create link'); } 175 return SUPA_URL + '/storage/v1' + data.signedURL; 176} 177 178async function deleteDocFile(path){ 179 var headers = await getAuthHeaders();
180 await fetch(SUPA_URL + '/storage/v1/object/' + DOC_BUCKET + '/' + path, { 181 method: 'DELETE', headers: headers 182 }); 183} 184 185async function deleteDocument(doc){ 186 // Remove the stored file first (best-effort; ignore if already gone) 187 if(doc.file_path){ try{ await deleteDocFile(doc.file_path); }catch(e){} } 188 // Remove the database row (authenticated so RLS permits it) 189 if(supaReady){ 190 var headers = await getAuthHeaders(); 191 var resp = await fetch(SUPA_URL + '/rest/v1/documents?id=eq.' + doc.id, { method: 'DELETE', headers: headers }); 192 if(!resp.ok){ var t = await resp.text(); throw new Error(t.substring(0,200)); } 193 } 194 // Remove from the in-memory list 195 var i = DOCS.findIndex(function(d){ return d.id === doc.id; }); 196 if(i >= 0) DOCS.splice(i, 1); 197} 198 199// Delete many documents at once: storage files sequentially (best-effort), then the DB rows 200// batched via "in.(...)" so we don't fire a request per document. 201async function bulkDeleteDocuments(docs, onProgress){ 202 var out = {ok:0, fail:0}; 203 if(!docs || !docs.length) return out; 204 for(var i=0; i<docs.length; i++){ 205 if(docs[i].file_path){ try{ await deleteDocFile(docs[i].file_path); }catch(e){} } 206 if(onProgress) onProgress(i+1, docs.length); 207 } 208 if(supaReady){ 209 var h = await getAuthHeaders(); 210 var ids = docs.map(function(d){ return d.id; }); 211 var CHUNK = 100; 212 for(var j=0; j<ids.length; j+=CHUNK){ 213 var list = '(' + ids.slice(j, j+CHUNK).join(',') + ')'; 214 try{ 215 var res = await fetch(SUPA_URL+'/rest/v1/documents?id=in.'+list, {method:'DELETE', headers:h}); 216 if(res.ok) out.ok++; else out.fail++; 217 }catch(e){ out.fail++; } 218 } 219 } 220 var gone = {}; 221 docs.forEach(function(d){ gone[String(d.id)] = true; }); 222 DOCS = DOCS.filter(function(d){ return !gone[String(d.id)]; }); 223 return out; 224} 225 226// Pull the fields we need for cross-document checks + AI advisory out of a raw scan result, 227// so they ride along on the stored document (documents.extracted jsonb). 228function buildDocExtract(r){ 229 if(!r) return null; 230 return { 231 docType: r.docType || '', 232 addendumNumber: r.addendumNumber || '', 233 effectiveDate: r.effectiveDate || '', 234 address: r.address || '', 235 buyerName: r.buyerName || '', 236 sellerName: r.sellerName || '', 237 purchasePrice: r.purchasePrice || '', 238 loanAmount: r.loanAmount || '', 239 earnestMoney: r.earnestMoney || '', 240 contractDate: r.contractDate || '', 241 closingDate: r.closingDate || '', 242 earnestMoneyDeadline: r.earnestMoneyDeadline || '', 243 dueDiligenceDeadline: r.dueDiligenceDeadline || '', 244 financingDeadline: r.financingDeadline || '', 245 appraisalDeadline: r.appraisalDeadline || '', 246 settlementDeadline: r.settlementDeadline || '', 247 lenderName: r.lenderName || '', 248 titleCompany: r.titleCompany || '', 249 listingCommissionPct: r.listingCommissionPct || '', 250 buyerCommissionPct: r.buyerCommissionPct || '', 251 mlsNumber: r.mlsNumber || '', 252 contingencies: Array.isArray(r.contingencies) ? r.contingencies : [], 253 redFlags: Array.isArray(r.redFlags) ? r.redFlags : [], 254 financing: (r.financing && typeof r.financing === 'object') ? r.financing : null, 255 repc: (r.repc && typeof r.repc === 'object') ? r.repc : null 256 }; 257} 258 259// ââ Financing type helpers ââââââââââââââââââââââââââââââââââââââââââââââââââ 260// A deal is bank-financed (conventional/FHA/VA), 'cash', or 'seller' (owner-carry). Cash and 261// seller-financed deals have no bank loan, so financing/appraisal contingencies don't apply. 262function normFinancingType(t){ 263 t = String(t || '').trim().toLowerCase(); 264 if(!t) return ''; 265 if(t.indexOf('seller')>=0 || t.indexOf('owner')>=0 || t.indexOf('carry')>=0 || t.indexOf('aitd')>=0 || t.indexOf('wrap')>=0 || t.indexOf('contract for deed')>=0) return 'seller'; 266 if(t.indexOf('cash')>=0) return 'cash'; 267 if(t.indexOf('conv')>=0 || t.indexOf('fha')>=0 || t.indexOf('va')>=0 || t.indexOf('loan')>=0 || t.indexOf('mortg')>=0 || t.indexOf('bank')>=0) return 'conventional'; 268 return ''; // unknown -> leave unset (treated as bank-financed by default) 269} 270// true when there's no bank loan on the deal (cash or seller-financed). 271function noBankLoan(tx){ return tx && (tx.financingType === 'cash' || tx.financingType === 'seller'); } 272function financingTypeLabel(t){ return ({conventional:'Conventional / bank loan', cash:'Cash', seller:'Seller financing'})[t] || ''; } 273// Pull seller-financing note terms out of a scan's financing object; null if none present. 274function sfTermsFromScan(fin){ 275 fin = fin || {}; 276 var s = function(v){ return v==null ? '' : String(v).trim(); }; 277 var sf = { 278 amount: s(fin.sellerAmount), rate: s(fin.sellerRate), termMonths: s(fin.sellerTermMonths), 279 amortMonths: s(fin.sellerAmortizationMonths), balloonDate: s(fin.sellerBalloonDate), 280 payment: s(fin.sellerMonthlyPayment), downPayment: s(fin.sellerDownPayment), 281 lateFee: s(fin.sellerLateFee), dueOnSale: s(fin.sellerDueOnSale) 282 }; 283 return Object.keys(sf).some(function(k){ return sf[k] !== ''; }) ? sf : null; 284} 285// The seller-financing terms stored on a transaction (details.sf), or {} if none. 286function txSF(tx){ return (tx && tx.details && tx.details.sf) ? tx.details.sf : {}; } 287 288// Deterministically classify financing from the transcribed REPC 2.1 payment lines. We trust the 289// scan to READ each line's label + amount, but decide the type in code (matching the label text) 290// rather than trusting the model's own judgment - that's what makes it reliable. Returns 291// {type, sellerAmount, loanAmount} or null when there are no usable lines. 292function classifyFinancingFromLines(lines){ 293 if(!Array.isArray(lines) || !lines.length) return null; 294 var num = function(v){ var n = parseFloat(String(v==null?'':v).replace(/[^0-9.]/g,'')); return isNaN(n)?0:n; }; 295 var sellerAmt = 0, loanAmt = 0, sawAny = false;
296 lines.forEach(function(l){ 297 var lab = String((l && l.label) || '').toLowerCase(); 298 var amt = num(l && l.amount); 299 if(!lab) return; 300 sawAny = true; 301 if(amt <= 0) return; 302 if(lab.indexOf('seller financ')>=0 || lab.indexOf('owner financ')>=0 || lab.indexOf('carry')>=0){ 303 sellerAmt = Math.max(sellerAmt, amt); 304 } else if(lab.indexOf('assum')<0 && lab.indexOf('balance')<0 && (lab.indexOf('new loan')>=0 || lab.indexOf('loan')>=0)){ 305 loanAmt = Math.max(loanAmt, amt); // a NEW bank loan (exclude "balance..." cash and "assumption") 306 } 307 }); 308 if(!sawAny) return null; 309 if(sellerAmt > 0) return { type:'seller', sellerAmount:String(sellerAmt), loanAmount:'' }; 310 if(loanAmt > 0) return { type:'conventional', sellerAmount:'', loanAmount:String(loanAmt) }; 311 return { type:'cash', sellerAmount:'', loanAmount:'' }; 312} 313 314// ââ REPC contract facts (details.repc) ââââââââââââââââââââââââââââââââââââââ 315function txREPC(tx){ return (tx && tx.details && tx.details.repc) ? tx.details.repc : {}; } 316// Contingency waivers read straight from the REPC's own checkboxes (8.1/8.2/8.3). When a box is 317// marked "is not" / "no", that contingency doesn't apply, so a missing deadline is not a problem. 318function ddWaived(tx){ return txREPC(tx).dueDiligenceApplies === 'is_not'; } 319function appraisalWaived(tx){ return txREPC(tx).appraisalApplies === 'is_not'; } 320function financingWaived(tx){ var f = txREPC(tx).financingRequired; return f === 'no' || f === false; } 321// Merge scanned REPC facts onto a deal additively (a later scan fills blanks, never wipes). 322function mergeREPCFacts(tx, repc){ 323 if(!repc || typeof repc !== 'object') return; 324 tx.details = tx.details || {}; 325 var cur = tx.details.repc || {}; 326 Object.keys(repc).forEach(function(k){ 327 var v = repc[k]; 328 if(Array.isArray(v)){ if(v.length) cur[k] = v; } 329 else if(v !== '' && v != null){ cur[k] = v; } 330 }); 331 tx.details.repc = cur; 332} 333// Human-readable [label, value] rows from stored REPC facts, skipping anything not captured. 334function repcFactRows(rp){ 335 rp = rp || {}; 336 var rows = []; 337 var who = function(w, o){ var m={seller:'Seller',buyer:'Buyer',split:'Split equally',other:'Other'}; var s=m[w]||''; if(w==='other'&&o) s+=' ('+o+')'; return s; }; 338 var party = function(v){ return v==='seller'?'Seller':v==='buyer'?'Buyer':''; }; 339 var inc = []; 340 if(Array.isArray(rp.includedItems) && rp.includedItems.length) inc.push(rp.includedItems.join(', ')); 341 if(rp.includedItemsOther) inc.push(rp.includedItemsOther); 342 if(inc.length) rows.push(['Included items (1.2)', inc.join('; ')]); 343 if(rp.personalPropertyIncluded === 'are') rows.push(['Personal property (1.2)', rp.personalPropertyList || 'Included']); 344 else if(rp.personalPropertyIncluded === 'are not') rows.push(['Personal property (1.2)', 'None included']); 345 if(rp.excludedItems) rows.push(['Excluded items (1.3)', rp.excludedItems]); 346 if(rp.waterRightsIncluded) rows.push(['Water rights incl. (1.4)', rp.waterRightsIncluded]); 347 if(rp.waterRightsExcluded) rows.push(['Water rights excl. (1.4)', rp.waterRightsExcluded]); 348 if(rp.possessionBasis){ 349 var pos = rp.possessionBasis==='upon_recording' ? 'Upon recording' 350 : rp.possessionBasis==='hours_after_recording' ? ((rp.possessionValue||'?')+' hours after recording') 351 : rp.possessionBasis==='days_after_recording' ? ((rp.possessionValue||'?')+' days after recording') : ''; 352 if(pos) rows.push(['Possession (3.3)', pos]); 353 } 354 if(rp.specialAssessmentsWho) rows.push(['Special assessments', who(rp.specialAssessmentsWho, rp.specialAssessmentsOther)]); 355 if(rp.hoaTransferFeeWho) rows.push(['HOA transfer fee (4.3c)', who(rp.hoaTransferFeeWho, rp.hoaTransferFeeOther)]); 356 if(rp.dueDiligenceApplies) rows.push(['Due diligence (8.1)', rp.dueDiligenceApplies==='is'?'Applies':'Waived (is not)']); 357 if(rp.appraisalApplies) rows.push(['Appraisal (8.2)', rp.appraisalApplies==='is'?'Applies':'Waived (is not)']); 358 if(rp.financingRequired) rows.push(['Financing (8.3)', rp.financingRequired==='yes'?'Required':'Not required']); 359 if(rp.earnestHardAmount && String(rp.earnestHardAmount).replace(/[^0-9.]/g,'') && parseFloat(rp.earnestHardAmount)>0) rows.push(['Earnest goes hard (8.3b-i)', '$'+rp.earnestHardAmount]); 360 if(rp.sellerPaidCostsWill) rows.push(['Seller-paid costs (8.4)', rp.sellerPaidCostsWill==='will' ? ('Yes'+(rp.sellerPaidCostsAmount?(' â $'+rp.sellerPaidCostsAmount):'')) : 'No']); 361 if(rp.addendaPresent){ 362 var ad = rp.addendaPresent==='are' ? 'Yes' : 'No'; 363 if(rp.addendaPresent==='are'){ 364 if(rp.addendaNumbers) ad += ' (#'+rp.addendaNumbers+')'; 365 if(Array.isArray(rp.addendaTypes) && rp.addendaTypes.length){ var tm={seller_financing:'Seller Financing',fha_va:'FHA/VA',other:'Other'}; ad += ' â '+rp.addendaTypes.map(function(t){return tm[t]||t;}).join(', '); } 366 } 367 rows.push(['Addenda (Sec 9)', ad]); 368 } 369 if(rp.homeWarrantyWill === 'will'){ 370 var parts=[]; if(party(rp.homeWarrantyPays)) parts.push('pays: '+party(rp.homeWarrantyPays)); 371 if(party(rp.homeWarrantyOrders)) parts.push('orders: '+party(rp.homeWarrantyOrders)); 372 if(party(rp.homeWarrantySelects)) parts.push('selects: '+party(rp.homeWarrantySelects));
373 var hw = 'Yes'; if(rp.homeWarrantyAmount) hw += ' (~$'+rp.homeWarrantyAmount+')'; if(parts.length) hw += ' â '+parts.join(', '); 374 rows.push(['Home warranty (10.1)', hw]); 375 } else if(rp.homeWarrantyWill === 'will not') rows.push(['Home warranty (10.1)', 'No']); 376 if(rp.mediation) rows.push(['Mediation (Sec 15)', rp.mediation==='shall'?'Shall mediate':'Optional (may)']); 377 return rows; 378} 379// Full grouped reference of every tracked REPC fact (value '' when not captured yet). 380function repcReferenceGroups(rp){ 381 rp = rp || {}; 382 var who = function(w,o){ if(!w) return ''; var m={seller:'Seller',buyer:'Buyer',split:'Split equally',other:'Other'}; var s=m[w]||''; if(w==='other'&&o) s+=' ('+o+')'; return s; }; 383 var party = function(v){ return v==='seller'?'Seller':v==='buyer'?'Buyer':''; }; 384 var inc=[]; if(Array.isArray(rp.includedItems)&&rp.includedItems.length) inc.push(rp.includedItems.join(', ')); if(rp.includedItemsOther) inc.push(rp.includedItemsOther); 385 var pp = rp.personalPropertyIncluded==='are' ? (rp.personalPropertyList||'Included') : rp.personalPropertyIncluded==='are not' ? 'None included' : ''; 386 var pos = rp.possessionBasis==='upon_recording' ? 'Upon recording' 387 : rp.possessionBasis==='hours_after_recording' ? ((rp.possessionValue||'?')+' hours after recording') 388 : rp.possessionBasis==='days_after_recording' ? ((rp.possessionValue||'?')+' days after recording') : ''; 389 var dd = rp.dueDiligenceApplies ? (rp.dueDiligenceApplies==='is'?'Applies':'Waived (is not)') : ''; 390 var ap = rp.appraisalApplies ? (rp.appraisalApplies==='is'?'Applies':'Waived (is not)') : ''; 391 var fin = rp.financingRequired ? (rp.financingRequired==='yes'?'Required':'Not required') : ''; 392 var hard = (rp.earnestHardAmount && parseFloat(String(rp.earnestHardAmount).replace(/[^0-9.]/g,''))>0) ? ('$'+rp.earnestHardAmount) : ''; 393 var spc = rp.sellerPaidCostsWill ? (rp.sellerPaidCostsWill==='will' ? ('Yes'+(rp.sellerPaidCostsAmount?(' â $'+rp.sellerPaidCostsAmount):'')) : 'No') : ''; 394 var addenda=''; if(rp.addendaPresent){ addenda = rp.addendaPresent==='are'?'Yes':'No'; 395 if(rp.addendaPresent==='are'){ if(rp.addendaNumbers) addenda+=' (#'+rp.addendaNumbers+')'; 396 if(Array.isArray(rp.addendaTypes)&&rp.addendaTypes.length){ var tm={seller_financing:'Seller Financing',fha_va:'FHA/VA',other:'Other'}; addenda+=' â '+rp.addendaTypes.map(function(t){return tm[t]||t;}).join(', '); } } } 397 var hw=''; if(rp.homeWarrantyWill==='will'){ var parts=[]; 398 if(party(rp.homeWarrantyPays)) parts.push('pays: '+party(rp.homeWarrantyPays)); 399 if(party(rp.homeWarrantyOrders)) parts.push('orders: '+party(rp.homeWarrantyOrders)); 400 if(party(rp.homeWarrantySelects)) parts.push('selects: '+party(rp.homeWarrantySelects)); 401 hw='Yes'; if(rp.homeWarrantyAmount) hw+=' (~$'+rp.homeWarrantyAmount+')'; if(parts.length) hw+=' â '+parts.join(', '); 402 } else if(rp.homeWarrantyWill==='will not') hw='No'; 403 var med = rp.mediation ? (rp.mediation==='shall'?'Shall mediate':'Optional (may)') : ''; 404 var payRows = []; 405 if(Array.isArray(rp.paymentLines)){ 406 rp.paymentLines.forEach(function(l){ 407 var lab = (l && l.label) ? String(l.label) : ''; if(!lab) return; 408 var amt = (l && l.amount!=null && String(l.amount).trim()!=='' && parseFloat(String(l.amount).replace(/[^0-9.]/g,''))>0) ? ('$'+l.amount) : ''; 409 payRows.push([lab, amt]); 410 }); 411 } 412 if(!payRows.length) payRows.push(['Section 2.1 lines', '']); 413 return [ 414 {title:'Payment breakdown (2.1)', rows:payRows}, 415 {title:'Property & Inclusions', rows:[ 416 ['Included items (1.2)', inc.join('; ')], 417 ['Personal property (1.2)', pp], 418 ['Excluded items (1.3)', rp.excludedItems||''], 419 ['Water rights incl. (1.4)', rp.waterRightsIncluded||''], 420 ['Water rights excl. (1.4)', rp.waterRightsExcluded||''], 421 ['Possession (3.3)', pos] 422 ]}, 423 {title:'Contingencies (Sec 8)', rows:[ 424 ['Due diligence (8.1)', dd], 425 ['Appraisal (8.2)', ap], 426 ['Financing (8.3)', fin], 427 ['Earnest goes hard (8.3b-i)', hard] 428 ]}, 429 {title:'Costs & Fees', rows:[ 430 ['Special assessments', who(rp.specialAssessmentsWho, rp.specialAssessmentsOther)], 431 ['HOA transfer fee (4.3c)', who(rp.hoaTransferFeeWho, rp.hoaTransferFeeOther)], 432 ['Seller-paid costs (8.4)', spc], 433 ['Home warranty (10.1)', hw] 434 ]}, 435 {title:'Addenda & Other', rows:[ 436 ['Addenda (Sec 9)', addenda], 437 ['Mediation (Sec 15)', med] 438 ]} 439 ]; 440} 441 442async function saveDocument(file, meta){ 443 meta = meta || {}; 444 var fname = file.name || 'document'; 445 var fsize = file.size || 0; 446 var dupe = DOCS.find(function(d){ 447 if(d.file_name !== fname) return false;
448 if((d.size||0) !== fsize) return false; 449 var sameContact = meta.contact_id && d.contact_id === meta.contact_id; 450 var sameTx = meta.transaction_id && d.transaction_id === meta.transaction_id; 451 return sameContact || sameTx; 452 }); 453 if(dupe){ throw new Error('That document is already attached to this record (' + fname + ').'); } 454 var path = docStoragePath(file, meta); 455 await uploadDocFile(file, path); 456 var doc = { 457 id: Date.now() + Math.floor(Math.random() * 1000), 458 contact_id: meta.contact_id || null, 459 transaction_id: meta.transaction_id || null, 460 file_path: path, 461 file_name: fname, 462 doc_type: meta.doc_type || null, 463 summary: meta.summary || null, 464 mime_type: file.type || null, 465 size: fsize, 466 extracted: meta.extracted || null // full scan extraction: powers cross-doc + AI advisory 467 }; 468 DOCS.push(doc); 469 if(supaReady) dbSave('documents', [doc]); 470 return doc; 471} 472 473async function openDocument(doc){ 474 var w = window.open('about:blank', '_blank'); 475 if(!w){ 476 alert('Your browser blocked the new tab. Please allow popups for this site, then try again.'); 477 return; 478 } 479 try{ 480 var url = await getDocUrl(doc.file_path); 481 w.location.href = url; 482 }catch(e){ 483 try{ w.close(); }catch(_){} 484 alert('Could not open document: ' + e.message); 485 } 486} 487 488async function openDocument(doc){ 489 var w = window.open('', '_blank'); 490 try{ 491 var url = await getDocUrl(doc.file_path); 492 if(w){ w.location = url; } else { window.location = url; } 493 }catch(e){ 494 if(w){ try{ w.close(); }catch(_){} } 495 alert('Could not open document: ' + e.message); 496 } 497} 498function docsForContact(cid){ return DOCS.filter(function(d){ return d.contact_id === cid; }); } 499function docsForTransaction(tid){ return DOCS.filter(function(d){ return d.transaction_id === tid; }); } 500// A document is "archived" once its deal closes. The file stays put in storage - this is a 501// view filter, so reopening the transaction brings its paperwork straight back. 502function docIsArchived(d){ 503 if(d.transaction_id == null) return false; 504 var t = TX.find(function(x){ return String(x.id)===String(d.transaction_id); }); 505 return !!(t && t.status === 'closed'); 506} 507var selectedDocs = new Set(); 508 509function openDocUpload(meta, onDone){ 510 var input = document.createElement('input'); 511 input.type = 'file'; 512 input.style.display = 'none'; 513 document.body.appendChild(input); 514 input.addEventListener('change', function(){ 515 var file = input.files && input.files[0]; 516 if(input.parentNode) input.parentNode.removeChild(input); 517 if(!file) return; 518 if(file.size > 20 * 1024 * 1024){ alert('File too large. Please use a file under 20MB.'); return; } 519 saveDocument(file, meta).then(function(){ 520 if(onDone) onDone(); 521 }).catch(function(e){ alert('Upload failed: ' + e.message); }); 522 }); 523 input.click(); 524} 525 526function buildDocRow(doc, onChange){ 527 var row = document.createElement('div'); 528 row.style.cssText = 'display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border);'; 529 var name = document.createElement('div'); 530 name.style.cssText = 'flex:1;font-size:18px;color:var(--accent);cursor:pointer;text-decoration:underline;word-break:break-word;'; 531 name.textContent = doc.file_name || 'Document'; 532 name.title = 'Open document'; 533 (function(d){ name.addEventListener('click', function(){ openDocument(d); }); })(doc); 534 var meta = document.createElement('div'); 535 meta.style.cssText = 'font-size:15px;color:var(--text3);white-space:nowrap;'; 536 var sizeKb = doc.size ? Math.round(doc.size/1024) + ' KB' : ''; 537 meta.textContent = [doc.doc_type, sizeKb].filter(Boolean).join(' - '); 538 var del = document.createElement('button'); 539 del.className = 'btn btn-d'; 540 del.style.cssText = 'font-size:14px;padding:4px 10px;flex-shrink:0;'; 541 del.textContent = 'Delete'; 542 (function(d){ del.addEventListener('click', function(){ 543 if(!confirm('Delete "' + (d.file_name||'this document') + '"? The file will be permanently removed.')) return; 544 deleteDocument(d).then(function(){ if(onChange) onChange(); }).catch(function(e){ alert('Delete failed: ' + e.message); }); 545 }); })(doc); 546 row.appendChild(name); row.appendChild(meta); row.appendChild(del); 547 return row; 548}// Failed cloud saves are queued here so we can surface them and retry, instead of silently 549// diverging from the database (the root cause of "saved locally but not synced" bugs). 550var _saveQueue = []; 551function _postRows(table, cleanRows){ 552 return getAuthHeaders({'Prefer':'resolution=merge-duplicates'}).then(function(headers){ 553 return fetch(SUPA_URL + '/rest/v1/' + table, { method:'POST', headers:headers, body: JSON.stringify(cleanRows) }); 554 }); 555} 556function dbSave(table, rows){ 557 if(!supaReady) return Promise.resolve(); 558 // Hold the 30s background poll for a few seconds so it can't reload and paint over the record we're 559 // about to write before the write is globally visible. 560 try{ pausePoll(8000); }catch(e){} 561 var cleanRows = JSON.parse(JSON.stringify(rows)).map(function(r){ return stripForDB(table, r); }); 562 return _postRows(table, cleanRows).then(function(r){ 563 if(!r.ok){ return r.text().then(function(t){ _onSaveFail(table, cleanRows, r.status + ' ' + String(t).slice(0,160)); }); } 564 _onSaveOk(); 565 }).catch(function(e){ _onSaveFail(table, cleanRows, e.message); }); 566} 567function _onSaveOk(){ if(!_saveQueue.length){ showSyncStatus('live'); hideSaveError(); } } 568function _onSaveFail(table, cleanRows, detail){ 569 console.log('DB SAVE FAILED on ' + table + ': ' + detail); 570 _saveQueue.push({ table: table, rows: cleanRows }); 571 showSyncStatus('error'); 572 showSaveError(detail); 573} 574function showSaveError(detail){ 575 var bar = document.getElementById('syncBar'); if(!bar) return; 576 bar.style.cssText = 'padding:8px 20px;border-bottom:1px solid var(--danger);display:flex;align-items:center;gap:10px;flex-shrink:0;background:rgba(201,76,76,0.12);'; 577 bar.innerHTML = ''; 578 var msg = document.createElement('div'); 579 msg.style.cssText = 'flex:1;font-size:14px;color:var(--danger);font-weight:600;'; 580 msg.textContent = 'â ' + _saveQueue.length + ' unsaved change' + (_saveQueue.length===1?'':'s') + ' â saved on this device but not the cloud.' + (detail ? (' (' + detail + ')') : ''); 581 var retry = document.createElement('button'); 582 retry.className = 'btn btn-p'; retry.style.cssText = 'font-size:13px;padding:4px 12px;flex:0 0 auto;'; retry.textContent = 'Retry sync'; 583 retry.addEventListener('click', retrySaves); 584 bar.appendChild(msg); bar.appendChild(retry); 585} 586function hideSaveError(){ var bar = document.getElementById('syncBar'); if(bar){ bar.style.display = 'none'; bar.innerHTML = ''; } } 587function retrySaves(){ 588 if(!supaReady || !_saveQueue.length){ hideSaveError(); return; }
589 var pending = _saveQueue.slice(); _saveQueue = []; hideSaveError(); showSyncStatus('loading'); 590 (function next(i){ 591 if(i >= pending.length){ _onSaveOk(); return; } 592 _postRows(pending[i].table, pending[i].rows).then(function(r){ 593 if(!r.ok){ return r.text().then(function(t){ _onSaveFail(pending[i].table, pending[i].rows, r.status + ' ' + String(t).slice(0,160)); next(i+1); }); } 594 next(i+1); 595 }).catch(function(e){ _onSaveFail(pending[i].table, pending[i].rows, e.message); next(i+1); }); 596 })(0); 597} 598 599function dbDelete(table, id){ dbDeleteBy(table, 'id', id); } 600 601// Self-cleaning sweep: removes rows that can no longer be valid so stale data can't accumulate 602// and resurface (e.g. in the daily briefing). Runs once per session after the first DB load. 603// Deletes only unambiguous garbage: 604// - a deadline/follow-up whose transaction was deleted (dangling transactionId) 605// - a deadline/follow-up (no transaction) whose contact was deleted (dangling contactId) 606// - a personal reminder (no transaction, no contact) whose date has already passed (fired/done) 607// It never touches items tied to an existing transaction or contact, or dated today/future. 608var _sweepDone = false; 609function sweepStaleItems(){ 610 if(_sweepDone || !supaReady) return; 611 _sweepDone = true; 612 var txIds = {}; TX.forEach(function(t){ txIds[String(t.id)] = true; }); 613 var cIds = {}; C.forEach(function(c){ cIds[String(c.id)] = true; }); 614 var todayStr = new Date().toISOString().slice(0,10); 615 function isStale(row){ 616 if(row.transactionId != null) return !txIds[String(row.transactionId)]; // deal deleted 617 if(row.contactId != null) return !cIds[String(row.contactId)]; // contact deleted 618 return row.date ? (String(row.date).slice(0,10) < todayStr) : false; // fired reminder 619 } 620 var delD = D.filter(isStale), delF = F.filter(isStale); 621 if(!delD.length && !delF.length) return; 622 pausePoll(15000); 623 delD.forEach(function(d){ dbDeleteBy('deadlines','id',d.id); }); 624 delF.forEach(function(f){ dbDeleteBy('followups','id',f.id); }); 625 var dGone={}; delD.forEach(function(d){ dGone[String(d.id)]=true; }); 626 var fGone={}; delF.forEach(function(f){ fGone[String(f.id)]=true; }); 627 D = D.filter(function(d){ return !dGone[String(d.id)]; }); 628 F = F.filter(function(f){ return !fGone[String(f.id)]; }); 629 sv(); 630 console.log('Swept '+delD.length+' stale deadline(s) and '+delF.length+' stale follow-up(s).'); 631 rd(); if(curPage==='deadlines') dlShowView(dlView); 632} 633 634function fetchAllRows(base, path, headers){ 635 var pageSize = 1000; 636 var all = []; 637 function nextPage(offset){ 638 var sep = path.indexOf('?') >= 0 ? '&' : '?'; 639 var url = base + path + sep + 'limit=' + pageSize + '&offset=' + offset; 640 // no-store: the 30s poll must read the live DB, never a browser-cached copy from a poll that ran 641 // just before a save â a stale copy would overwrite freshly-saved edits (e.g. a new comms row). 642 return fetch(url, {headers: headers, cache: 'no-store'}).then(function(r){ return r.json(); }).then(function(j){ 643 if(!Array.isArray(j)) return j; 644 all = all.concat(j); 645 if(j.length < pageSize) return all; 646 return nextPage(offset + pageSize); 647 }); 648 } 649 return nextPage(0); 650} 651 652async function loadFromDB(){ 653 if(!supaReady) return false; 654 try{ 655 showSyncStatus('loading'); 656 657 // Use direct fetch to Supabase REST API to avoid DataCloneError 658 var headers = await getAuthHeaders(); 659 var base = SUPA_URL + '/rest/v1/'; 660 661 var results = await Promise.all([ 662 fetchAllRows(base, 'contacts?order=added.asc,id.asc', headers), 663 fetchAllRows(base, 'notes?order=date.asc,id.asc', headers), 664 fetchAllRows(base, 'followups?order=date.asc,id.asc', headers), 665 fetchAllRows(base, 'deadlines?order=date.asc,id.asc', headers), 666 fetchAllRows(base, 'transactions?order=created_at.asc,id.asc', headers), 667 fetchAllRows(base, 'campaigns?order=created_at.asc,id.asc', headers).catch(function(){return [];}), 668 fetchAllRows(base, 'enrollments?order=created_at.asc,id.asc', headers).catch(function(){return [];}), 669 fetchAllRows(base, 'send_log?order=created_at.asc,id.asc', headers).catch(function(){return []; }), 670 fetchAllRows(base, 'documents?order=created_at.asc,id.asc', headers).catch(function(){return []; }), 671 fetchAllRows(base, 'tx_changes?order=id.asc', headers).catch(function(){return []; }), 672 fetchAllRows(base, 'reminder_settings?order=id.asc', headers).catch(function(){return []; }), 673 fetchAllRows(base, 'team?order=id.asc', headers).catch(function(){return []; }), 674 fetchAllRows(base, 'reminder_log?order=id.desc', headers).catch(function(){return []; }), 675 fetchAllRows(base, 'inv_hoa?order=id.asc', headers).catch(function(){return []; }), 676 fetchAllRows(base, 'inv_properties?order=id.asc', headers).catch(function(){return []; }), 677 fetchAllRows(base, 'inv_units?order=id.asc', headers).catch(function(){return []; }), 678 fetchAllRows(base, 'inv_ledger?order=date.asc,id.asc', headers).catch(function(){return []; }), 679 fetchAllRows(base, 'inv_loans?order=id.asc', headers).catch(function(){return []; }), 680 fetchAllRows(base, 'inv_loan_payments?order=id.asc', headers).catch(function(){return []; }), 681 fetchAllRows(base, 'inv_payees?order=id.asc', headers).catch(function(){return []; }), 682 fetchAllRows(base, 'inv_scan_log?order=ran_at.desc&limit=25', headers).catch(function(){return []; }), 683 fetchAllRows(base, 'deal_sheets?order=updated_at.desc', headers).catch(function(){return []; }), 684 fetchAllRows(base, 'loi_letters?order=updated_at.desc', headers).catch(function(){return []; }), 685 fetchAllRows(base, 'listings?order=updated_at.desc', headers).catch(function(){return []; }), 686 fetchAllRows(base, 'inv_recurring?order=id.asc', headers).catch(function(){return []; }), 687 fetchAllRows(base, 'commercial_prospects?order=updated_at.desc', headers).catch(function(){return []; }), 688 fetchAllRows(base, 'nc_communities?order=updated_at.desc', headers).catch(function(){return []; }) 689 ]); 690 var rc = results[0], rn = results[1], rf = results[2], rd = results[3], rtx = results[4]; 691 692 // Check for errors (Supabase returns {code, message} on error) 693 if(rc && rc.code){ console.log('contacts load error:', rc.message); showSyncStatus('error'); return false; } 694 if(rn && rn.code) console.log('notes load error:', rn.message); 695 if(rf && rf.code) console.log('followups load error:', rf.message); 696 if(rd && rd.code) console.log('deadlines load error:', rd.message); 697 if(rtx && rtx.code) console.log('transactions load error:', rtx.message); 698 699 if(Array.isArray(rc)) C = rc; 700 if(Array.isArray(rn)) N = rn; 701 if(Array.isArray(rf)) F = rf; 702 if(Array.isArray(rd)) D = rd; 703 if(Array.isArray(rtx)) TX = rtx; 704 var rcamp = results[5], renr = results[6], rsl = results[7], rdoc = results[8]; 705 if(Array.isArray(rcamp)) CAMP = rcamp; 706 if(Array.isArray(renr)) ENR = renr; 707 if(Array.isArray(rsl)) SENDLOG = rsl; 708 if(Array.isArray(rdoc)) DOCS = rdoc; 709 var rch = results[9]; 710 if(Array.isArray(rch)) CH = rch; 711 var rrs = results[10]; 712 if(Array.isArray(rrs)) RS = rrs; 713 // The team comes from the database so member ids are the same on every machine. 714 var rtm = results[11]; 715 if(Array.isArray(rtm) && rtm.length) TM = rtm; 716 var rrl = results[12]; 717 if(Array.isArray(rrl)) RL = rrl; 718 if(Array.isArray(results[13])) IHOA = results[13]; 719 if(Array.isArray(results[14])) IPROP = results[14]; 720 if(Array.isArray(results[15])) IUNIT = results[15]; 721 if(Array.isArray(results[16])) ILED = results[16]; 722 if(Array.isArray(results[17])) ILOAN = results[17]; 723 if(Array.isArray(results[18])) ILPAY = results[18]; 724 if(Array.isArray(results[19])) IPAYEE = results[19];
725 if(Array.isArray(results[20])) ISCAN = results[20]; 726 if(Array.isArray(results[21])) DSHEET = results[21]; 727 if(Array.isArray(results[22])) LOIS = results[22]; 728 if(Array.isArray(results[23])) LISTINGS = results[23]; 729 if(Array.isArray(results[24])) INVREC = results[24]; 730 if(Array.isArray(results[25])) CPROS = results[25]; 731 if(Array.isArray(results[26])) NC = results[26]; 732 try{ materializeRecurring(); }catch(e){} 733 DOCS.forEach(function(d){ 734 d.id = typeof d.id === 'string' ? parseInt(d.id)||d.id : d.id; 735 if(d.contact_id != null) d.contact_id = typeof d.contact_id === 'string' ? parseInt(d.contact_id) : d.contact_id; 736 if(d.transaction_id != null) d.transaction_id = typeof d.transaction_id === 'string' ? parseInt(d.transaction_id) : d.transaction_id; 737 }); 738 CAMP.forEach(function(x){ if(typeof x.steps === 'string'){ try{ x.steps = JSON.parse(x.steps); }catch(e){ x.steps = []; } } if(!Array.isArray(x.steps)) x.steps = []; }); 739 // Convert id strings to numbers if needed 740 C.forEach(function(c){ c.id = typeof c.id === 'string' ? parseInt(c.id)||c.id : c.id; }); 741 N.forEach(function(n){ n.id = typeof n.id === 'string' ? parseInt(n.id)||n.id : n.id; n.contactId = typeof n.contactId === 'string' ? parseInt(n.contactId) : n.contactId; }); 742 F.forEach(function(f){ f.id = typeof f.id === 'string' ? parseInt(f.id)||f.id : f.id; f.contactId = typeof f.contactId === 'string' ? parseInt(f.contactId) : f.contactId; }); 743 D.forEach(function(d){ d.id = typeof d.id === 'string' ? parseInt(d.id)||d.id : d.id; d.contactId = typeof d.contactId === 'string' ? parseInt(d.contactId) : d.contactId; }); 744 sv(); 745 showSyncStatus('live'); 746 return true; 747 }catch(e){ console.log('loadFromDB error:', e); showSyncStatus('error'); return false; } 748} 749 750function showSyncStatus(status){ 751 // Old full-width bar retired in favor of the compact top-bar dot. 752 var bar = document.getElementById('syncBar'); 753 if(bar) bar.style.display = 'none'; 754 var dot = document.getElementById('syncDotIcon'); 755 if(!dot) return; 756 var color, tip; 757 if(status === 'live'){ color = 'var(--lead)'; tip = 'Cloud sync active - click to disconnect'; } 758 else if(status === 'loading'){ color = 'var(--accent)'; tip = 'Connecting to cloud...'; } 759 else if(status === 'error'){ color = 'var(--danger)'; tip = 'Cloud sync error - using local data'; } 760 else { color = 'var(--danger)'; tip = 'Cloud sync off - click to reconnect'; } 761 dot.style.background = color; 762 if(dot.parentNode) dot.parentNode.title = tip; 763} 764 765// Wrapped save functions that sync to Supabase 766function saveContact(c){ sv(); if(supaReady) dbSave('contacts', [c]); } 767function saveNote(n){ sv(); if(supaReady) dbSave('notes', [n]); } 768function logActivity(contactId,type){A.unshift({contactId:contactId,type:type,date:new Date().toISOString()});if(A.length>30)A=A.slice(0,30);sv();} 769function saveFU(f){ sv(); if(supaReady) dbSave('followups', [f]); } 770function saveDL(d){ sv(); if(supaReady) dbSave('deadlines', [d]); } 771// ---- Investments (rental/investment property tracking) ---- 772function saveInvHoa(x){ sv(); if(supaReady) dbSave('inv_hoa', [x]); } 773function saveInvProp(x){ sv(); if(supaReady) dbSave('inv_properties', [x]); } 774function saveInvUnit(x){ sv(); if(supaReady) dbSave('inv_units', [x]); } 775function saveInvLedger(x){ sv(); if(supaReady) dbSave('inv_ledger', [x]); } 776function delInvHoa(id){ IHOA = IHOA.filter(function(x){return String(x.id)!==String(id);}); IPROP.forEach(function(p){ if(String(p.hoa_id)===String(id)){ p.hoa_id=null; saveInvProp(p); } }); sv(); if(supaReady) dbDeleteBy('inv_hoa','id',id); } 777function delInvProp(id){ 778 var units = IUNIT.filter(function(u){return String(u.property_id)===String(id);}); 779 IUNIT = IUNIT.filter(function(u){return String(u.property_id)!==String(id);}); 780 ILED = ILED.filter(function(l){return String(l.property_id)!==String(id);}); 781 IPROP = IPROP.filter(function(x){return String(x.id)!==String(id);}); 782 sv(); 783 if(supaReady){ dbDeleteBy('inv_units','property_id',id); dbDeleteBy('inv_ledger','property_id',id); dbDeleteBy('inv_properties','id',id); } 784} 785function delInvUnit(id){ IUNIT = IUNIT.filter(function(x){return String(x.id)!==String(id);});
785 ILED.forEach(function(l){ if(String(l.unit_id)===String(id)){ l.unit_id=null; saveInvLedger(l); } }); sv(); if(supaReady) dbDeleteBy('inv_units','id',id); } 786function delInvLedger(id){ ILED = ILED.filter(function(x){return String(x.id)!==String(id);}); sv(); if(supaReady) dbDeleteBy('inv_ledger','id',id); } 787function saveInvPayee(x){ sv(); if(supaReady) dbSave('inv_payees', [x]); } 788function delInvPayee(id){ IPAYEE = IPAYEE.filter(function(x){return String(x.id)!==String(id);}); sv(); if(supaReady) dbDeleteBy('inv_payees','id',id); } 789function saveInvRecurring(x){ sv(); if(supaReady) dbSave('inv_recurring', [x]); } 790function delInvRecurring(id){ INVREC = INVREC.filter(function(x){return String(x.id)!==String(id);}); sv(); if(supaReady) dbDeleteBy('inv_recurring','id',id); } 791function saveDealSheet(x){ x.updated_at=new Date().toISOString(); sv(); if(supaReady) dbSave('deal_sheets', [x]); } 792function delDealSheet(id){ DSHEET = DSHEET.filter(function(x){return String(x.id)!==String(id);}); sv(); if(supaReady) dbDeleteBy('deal_sheets','id',id); } 793function saveLoi(x){ x.updated_at=new Date().toISOString(); sv(); if(supaReady) dbSave('loi_letters', [x]); } 794function delLoi(id){ LOIS = LOIS.filter(function(x){return String(x.id)!==String(id);}); sv(); if(supaReady) dbDeleteBy('loi_letters','id',id); } 795function saveListing(x){ x.updated_at=new Date().toISOString(); sv(); if(supaReady) dbSave('listings', [x]); } 796function delListing(id){ LISTINGS = LISTINGS.filter(function(x){return String(x.id)!==String(id);}); sv(); if(supaReady) dbDeleteBy('listings','id',id); } 797function saveProspect(x){ x.updated_at=new Date().toISOString(); sv(); if(supaReady) dbSave('commercial_prospects', [x]); } 798function delProspect(id){ CPROS = CPROS.filter(function(x){return String(x.id)!==String(id);}); sv(); if(supaReady) dbDeleteBy('commercial_prospects','id',id); } 799function saveNC(x){ x.updated_at=new Date().toISOString(); sv(); if(supaReady) dbSave('nc_communities', [x]); } 800function delNC(id){ NC = NC.filter(function(x){return String(x.id)!==String(id);}); sv(); if(supaReady) dbDeleteBy('nc_communities','id',id); } 801// lookups 802function invProp(id){ return IPROP.find(function(p){return String(p.id)===String(id);}) || null; } 803function invUnit(id){ return IUNIT.find(function(u){return String(u.id)===String(id);}) || null; } 804function invHoa(id){ return IHOA.find(function(h){return String(h.id)===String(id);}) || null; } 805function unitsForProp(id){ return IUNIT.filter(function(u){return String(u.property_id)===String(id);}); } 806// Hard money 807function saveLoan(x){ sv(); if(supaReady) dbSave('inv_loans', [x]); } 808function saveLoanPay(x){ sv(); if(supaReady) dbSave('inv_loan_payments', [x]); } 809function delLoan(id){ ILOAN = ILOAN.filter(function(x){return String(x.id)!==String(id);}); ILPAY = ILPAY.filter(function(p){return String(p.loan_id)!==String(id);}); sv(); if(supaReady){ dbDeleteBy('inv_loan_payments','loan_id',id); dbDeleteBy('inv_loans','id',id); } } 810function delLoanPay(id){ ILPAY = ILPAY.filter(function(x){return String(x.id)!==String(id);}); sv(); if(supaReady) dbDeleteBy('inv_loan_payments','id',id); } 811function invLoan(id){ return ILOAN.find(function(l){return String(l.id)===String(id);}) || null; } 812function paymentsForLoan(id){ return ILPAY.filter(function(p){return String(p.loan_id)===String(id);}); } 813function saveCH(x){ sv(); if(supaReady) dbSave('tx_changes', [x]); } 814function saveRS(x){ if(supaReady) dbSave('reminder_settings', [x]); } 815function deleteRSfromDB(id){ 816 if(!supaReady) return; 817 getAuthHeaders().then(function(h){ 818 fetch(SUPA_URL+'/rest/v1/reminder_settings?id=eq.'+id, {method:'DELETE', headers:h}); 819 }); 820} 821// Team members MUST be shared, not per-browser. Member ids key gmail_tokens, assignedTo, 822// calendar filters and drip senders - inventing them locally silently breaks all of it. 823function saveTM(m){ sv(); if(supaReady) dbSave('team', [m]); } 824function deleteTMfromDB(id){ 825 if(!supaReady) return; 826 getAuthHeaders().then(function(h){ 827 fetch(SUPA_URL+'/rest/v1/team?id=eq.'+id, {method:'DELETE', headers:h}); 828 }); 829} 830function deleteCfromDB(id){ 831 if(!supaReady) return; 832 getAuthHeaders().then(function(h){ 833 fetch(SUPA_URL+'/rest/v1/contacts?id=eq.'+id,{method:'DELETE',headers:h}); 834 fetch(SUPA_URL+'/rest/v1/notes?contactId=eq.'+id,{method:'DELETE',headers:h}); 835 fetch(SUPA_URL+'/rest/v1/followups?contactId=eq.'+id,{method:'DELETE',headers:h}); 836 fetch(SUPA_URL+'/rest/v1/deadlines?contactId=eq.'+id,{method:'DELETE',headers:h}); 837 }); 838} 839 840// Bulk delete: one request per table per chunk using PostgREST "in.(...)", awaited in 841// sequence. Deleting one-by-one fires 4 unawaited fetches per contact, which floods the 842// connection pool and silently drops most of them past a few hundred rows. 843async function bulkDeleteContactsFromDB(ids, onProgress){ 844 var out = {ok:0, fail:0}; 845 if(!supaReady || !ids || !ids.length) return out;
846 var h = await getAuthHeaders(); 847 var CHUNK = 100; // keeps the URL well under length limits 848 var targets = [ // children first, then the contact rows 849 {t:'notes', col:'contactId'}, 850 {t:'followups', col:'contactId'}, 851 {t:'deadlines', col:'contactId'}, 852 {t:'contacts', col:'id'} 853 ]; 854 for(var i=0; i<ids.length; i+=CHUNK){ 855 var list = '(' + ids.slice(i, i+CHUNK).join(',') + ')'; 856 for(var k=0; k<targets.length; k++){ 857 try{ 858 var res = await fetch(SUPA_URL+'/rest/v1/'+targets[k].t+'?'+targets[k].col+'=in.'+list, 859 {method:'DELETE', headers:h}); 860 if(res.ok) out.ok++; else out.fail++; 861 }catch(e){ out.fail++; } 862 } 863 if(onProgress) onProgress(Math.min(i+CHUNK, ids.length), ids.length); 864 } 865 return out; 866} 867function deleteFUfromDB(id){ 868 if(!supaReady) return; 869 getAuthHeaders().then(function(h){ 870 fetch(SUPA_URL+'/rest/v1/followups?id=eq.'+id,{method:'DELETE',headers:h}); 871 }); 872} 873function deleteNotefromDB(id){ 874 if(!supaReady) return; 875 getAuthHeaders().then(function(h){ 876 fetch(SUPA_URL+'/rest/v1/notes?id=eq.'+id,{method:'DELETE',headers:h}); 877 }); 878} 879function deleteDLfromDB(id){ 880 if(!supaReady) return; 881 getAuthHeaders().then(function(h){ 882 fetch(SUPA_URL+'/rest/v1/deadlines?id=eq.'+id,{method:'DELETE',headers:h}); 883 }); 884} 885// Delete rows from a table by a column match (used for cascade deletes). 886function dbDeleteBy(table, col, val){ 887 if(!supaReady) return; 888 getAuthHeaders({'Prefer':'return=representation'}).then(function(h){ 889 fetch(SUPA_URL+'/rest/v1/'+table+'?'+col+'=eq.'+val, {method:'DELETE', headers:h}) 890 .then(function(r){ 891 if(!r.ok){ 892 r.text().then(function(t){ console.log('DB DELETE failed on '+table+' ('+col+'='+val+'):', r.status, t); }); 893 showSyncStatus('error'); 894 return; 895 } 896 // 200 with an empty array = RLS silently blocked the delete (no matching DELETE policy). 897 r.json().then(function(rows){ 898 if(Array.isArray(rows) && rows.length===0){ 899 console.log('DB DELETE affected 0 rows on '+table+' ('+col+'='+val+') - likely a missing RLS delete policy. Row will return on next sync.'); 900 showSyncStatus('error'); 901 } 902 }).catch(function(){}); 903 }) 904 .catch(function(e){ console.log('DB DELETE fetch error on '+table+':', e.message); showSyncStatus('error'); }); 905 }); 906} 907 908// Reusable delete-confirmation dialog. Shows a permanent-delete warning and a checkbox list of 909// associated data that WILL also be deleted - unchecking an item keeps it. onConfirm receives an 910// object of the checked (to-delete) cascade ids, e.g. {docs:true, deadlines:true}. 911var _delOnConfirm = null; 912function openDeleteConfirm(cfg){ 913 cfg = cfg || {}; 914 if(ge('delTitle')) ge('delTitle').textContent = cfg.title || 'Confirm delete'; 915 if(ge('delWarn')) ge('delWarn').textContent = cfg.warnLine || 'This is permanent and cannot be undone.'; 916 var cascades = cfg.cascades || []; 917 if(ge('delSubtitle')) ge('delSubtitle').textContent = cascades.length 918 ? 'This will also permanently delete the following. Uncheck anything you want to keep:' 919 : ''; 920 var wrap = ge('delCascadeWrap'); 921 if(wrap){ 922 wrap.innerHTML = ''; 923 cascades.forEach(function(c){ 924 var lab = document.createElement('label'); 925 lab.style.cssText = 'display:flex;align-items:center;gap:9px;padding:8px 0;font-size:15px;color:var(--text);cursor:pointer;border-bottom:1px solid var(--border);'; 926 var cb = document.createElement('input'); cb.type = 'checkbox'; 927 cb.checked = (c.defaultChecked !== false); 928 cb.setAttribute('data-cascade', c.id); 929 cb.style.cssText = 'accent-color:var(--danger);width:18px;height:18px;flex:0 0 auto;'; 930 lab.appendChild(cb); lab.appendChild(document.createTextNode(c.label)); 931 wrap.appendChild(lab); 932 }); 933 } 934 _delOnConfirm = cfg.onConfirm || null; 935 om('delModal'); 936} 937 938function deleteTXfromDB(id){ 939 if(!supaReady) return; 940 getAuthHeaders().then(function(h){ 941 fetch(SUPA_URL+'/rest/v1/transactions?id=eq.'+id,{method:'DELETE',headers:h}); 942 fetch(SUPA_URL+'/rest/v1/deadlines?transactionId=eq.'+id,{method:'DELETE',headers:h}); 943 fetch(SUPA_URL+'/rest/v1/followups?transactionId=eq.'+id,{method:'DELETE',headers:h}); 944 fetch(SUPA_URL+'/rest/v1/notes?transactionId=eq.'+id,{method:'DELETE',headers:h}); 945 fetch(SUPA_URL+'/rest/v1/tx_changes?transactionId=eq.'+id,{method:'DELETE',headers:h}); 946 }); 947} 948function updateContact(c){ sv(); if(supaReady) dbSave('contacts', [c]); } 949 950// Real-time subscription for live sync 951var _pollInterval = null; 952var _pollPaused = false;
953function pausePoll(ms){ 954 _pollPaused = true; 955 setTimeout(function(){ _pollPaused = false; }, ms||10000); 956} 957function subscribeRealtime(){ 958 if(!supaReady) return; 959 if(_pollInterval) clearInterval(_pollInterval); 960 _pollInterval = setInterval(function(){ 961 if(_pollPaused) return; 962 loadFromDB().then(function(ok){ 963 if(ok && curPage){ 964 if(curPage==='dashboard') rd(); 965 else if(curPage==='contacts') rc(); 966 else if(curPage==='followups') rfu(); 967 else if(curPage==='notes') rn(); 968 else if(curPage==='deadlines') rdl(); 969 else if(curPage==='briefing') rb(); 970 else if(curPage==='tc') renderTC(); 971 else if(curPage==='drips'){ processDrips(); renderDrips(); } 972 } 973 }); 974 }, 30000); // Poll every 30 seconds 975} 976function demo(){ 977 var t=Date.now(); 978 C=[ 979 {id:1,first:'Marcus',last:'Thompson',type:'buyer',phone:'(555) 201-4433',email:'[email protected]',property:'Budget $480k-$550k, 3br',stage:'Showing',price:'$510,000',notes:'Pre-approved. Loves modern kitchens.',added:new Date(t-7*864e5).toISOString()}, 980 {id:2,first:'Linda',last:'Reyes',type:'seller',phone:'(555) 388-9021',email:'[email protected]',property:'4712 Maple Drive',stage:'Under Contract',price:'$625,000',notes:'Motivated seller, relocating for work.',added:new Date(t-14*864e5).toISOString()}, 981 {id:3,first:'James',last:'Park',type:'lead',phone:'(555) 477-1102',email:'[email protected]',property:'Riverside area, 2-3br',stage:'New Lead',price:'$350,000',notes:'Found via Zillow. First-time buyer.',added:new Date(t-2*864e5).toISOString()}, 982 {id:4,first:'Ava',last:'Mitchell',type:'buyer',phone:'(555) 590-3344',email:'[email protected]',property:'22 Birchwood Lane',stage:'Under Contract',price:'$415,000',notes:'Closing soon. Inspection done.',added:new Date(t-20*864e5).toISOString()}, 983 {id:5,first:'Carlos',last:'Vega',type:'lead',phone:'(555) 612-8800',email:'[email protected]',property:'Downtown condo $275k',stage:'Contacted',price:'$275,000',notes:'Investor. Cash buyer.',added:new Date(t-864e5).toISOString()}, 984 {id:6,first:'Diane',last:'Foster',type:'seller',phone:'(555) 703-9910',email:'[email protected]',property:'89 Crestwood Blvd',stage:'New Lead',price:'$549,000',notes:'Inherited property, wants quick sale.',added:new Date(t-3*864e5).toISOString()}, 985 {id:7,first:'Ryan',last:'Nguyen',type:'buyer',phone:'(555) 814-2267',email:'[email protected]',property:'4br, top school district',stage:'Contacted',price:'$650,000',notes:'Moving from out of state. Pre-approved $680k.',added:new Date(t-5*864e5).toISOString()}, 986 {id:8,first:'Sofia',last:'Martinez',type:'lead',phone:'(555) 925-3381',email:'[email protected]',property:'Townhouse $200k-$250k',stage:'New Lead',price:'$225,000',notes:'First-time buyer. Needs lender referral.',added:new Date(t-864e5).toISOString()} 987 ]; 988 N=[ 989 {id:1,contactId:1,text:'Showed 3 homes on Oak St. Liked 14 Oak the most. Wants to revisit before making offer.',date:new Date(t-864e5).toISOString()}, 990 {id:2,contactId:1,text:'Called to check in. Still deciding between 14 Oak and 22 Birch.',date:new Date(t-3*864e5).toISOString()}, 991 {id:3,contactId:2,text:'Buyer inspection done. Minor HVAC issue - $2k credit agreed.',date:new Date(t-2*864e5).toISOString()}, 992 {id:4,contactId:2,text:'Listing went live. 6 showings in first 48 hours. Received 2 offers.',date:new Date(t-10*864e5).toISOString()}, 993 {id:5,contactId:3,text:'Called back James. Very interested. Sending pre-qual checklist.',date:new Date(t-3600e3).toISOString()}, 994 {id:6,contactId:4,text:'Appraisal at value. Financing contingency cleared.',date:new Date(t-2*864e5).toISOString()}, 995 {id:7,contactId:5,text:'Coffee meeting. Carlos is a cash buyer looking at multi-unit condos.',date:new Date(t-5*3600e3).toISOString()}, 996 {id:8,contactId:7,text:'Virtual showing went well. Ryan loved Maplewood listing.',date:new Date(t-7200e3).toISOString()} 997 ]; 998 F=[ 999 {id:1,contactId:1,label:'Call Marcus - decision on 14 Oak St offer',date:new Date(t).toISOString().split('T')[0],pri:'hot',done:false}, 1000 {id:2,contactId:3,label:'Send James pre-qual checklist',date:new Date(t).toISOString().split('T')[0],pri:'hot',done:false}, 1001 {id:3,contactId:2,label:'Follow up after buyer inspection',date:new Date(t).toISOString().split('T')[0],pri:'hot',done:false}, 1002 {id:4,contactId:5,label:'Send Carlos condo listings',date:new Date(t+864e5).toISOString().split('T')[0],pri:'warn',done:false}, 1003 {id:5,contactId:7,label:'Schedule in-person showing for Ryan',date:new Date(t+864e5).toISOString().split('T')[0],pri:'warn',done:false}, 1004 {id:6,contactId:6,label:'Call Diane about listing strategy',date:new Date(t+2*864e5).toISOString().split('T')[0],pri:'warn',done:false}, 1005 {id:7,contactId:8,label:'Send Sofia lender contact info',date:new Date(t+2*864e5).toISOString().split('T')[0],pri:'ok',done:false} 1006 ]; 1007 D=[ 1008 {id:1,contactId:2,type:'Inspection Period Ends',date:new Date(t+864e5).toISOString().split('T')[0]}, 1009 {id:2,contactId:2,type:'Financing Contingency',date:new Date(t+3*864e5).toISOString().split('T')[0]}, 1010 {id:3,contactId:4,type:'Closing Date',date:new Date(t+10*864e5).toISOString().split('T')[0]}, 1011 {id:4,contactId:4,type:'Final Walkthrough',date:new Date(t+9*864e5).toISOString().split('T')[0]}, 1012 {id:5,contactId:1,type:'Offer Expiration',date:new Date(t+2*864e5).toISOString().split('T')[0]}, 1013 {id:6,contactId:7,type:'Pre-Approval Expires',date:new Date(t+14*864e5).toISOString().split('T')[0]} 1014 ]; 1015 sv(); 1016} 1017function ini(c){return (c.first[0]+c.last[0]).toUpperCase();} 1018function fn(c){return c.first+' '+c.last;} 1019// Single source of truth for a contact's language. Set once on the contact; every reminder, 1020// check-in and campaign email reads it from here. Defaults to English. 1021function clang(c){ return (c && c.lang === 'es') ? 'es' : 'en'; } 1022// Pick the right language variant: t({en:'Hi', es:'Hola'}, contact) 1023function t(variants, c){ 1024 var L = clang(c); 1025 if(!variants) return ''; 1026 return (variants[L] != null && variants[L] !== '') ? variants[L] : (variants.en || ''); 1027} 1028function ctypes(c){ 1029 // Always returns an array of types. Handles new array, legacy single str
1029ing, 1030 // and pipe-joined string stored in the 'type' column for Supabase persistence. 1031 if(Array.isArray(c.types) && c.types.length) return c.types; 1032 if(c.type){ 1033 if(c.type.indexOf('|')>=0) return c.type.split('|').filter(Boolean); 1034 return [c.type]; 1035 } 1036 return []; 1037} 1038function ctype1(c){ var t=ctypes(c); return t[0]||''; } // primary type for CSS classes etc 1039function gc(id){for(var i=0;i<C.length;i++){if(String(C[i].id)===String(id))return C[i];}return null;} 1040function du(s){var d=pld(s);d.setHours(0,0,0,0);var n=new Date();n.setHours(0,0,0,0);return Math.round((d-n)/864e5);} 1041function rt(iso){var m=Math.round((Date.now()-new Date(iso))/60000);if(m<1)return 'just now';if(m<60)return m+'m ago';if(m<1440)return Math.round(m/60)+'h ago';return Math.round(m/1440)+'d ago';} 1042function pld(s){if(typeof s==='string'&&/^\d{4}-\d{2}-\d{2}$/.test(s)){var p=s.split('-');return new Date(+p[0],+p[1]-1,+p[2]);}return new Date(s);} 1043function fd(s){return pld(s).toLocaleDateString('en-US',{month:'short',day:'numeric'});} 1044function tod(){return new Date().toISOString().split('T')[0];} 1045function dc(n){return n<=3?'dot-r':n<=7?'dot-y':'dot-g';} 1046function pl(p){return p==='hot'?'HOT':p==='warn'?'SOON':'OK';} 1047function pb(p){return p==='hot'?'b-hot':p==='warn'?'b-warn':'b-ok';} 1048function ge(id){return document.getElementById(id);} 1049function ck(){var s=document.createElementNS('http://www.w3.org/2000/svg','svg');s.setAttribute('width','9');s.setAttribute('height','9');s.setAttribute('fill','none');s.setAttribute('stroke','#0d0f14');s.setAttribute('stroke-width','3');s.setAttribute('viewBox','0 0 24 24');var p=document.createElementNS('http://www.w3.org/2000/svg','polyline');p.setAttribute('points','20,6 9,17 4,12');s.appendChild(p);return s;} 1050var pn={briefing:'Daily Briefing',dashboard:'Dashboard',pipeline:'Pipeline',contacts:'Contacts',followups:'Follow-ups',notes:'Notes',deadlines:'Deadlines',documents:'Documents',tc:'Transactions',scanner:'Doc Scanner',cardscanner:'Card Scanner',calendar:'Calendar',team:'Team',gmail:'Gmail',mls:'MLS Search',drips:'Drip Campaigns',investments:'Properties',hardmoney:'Hard Money'}; 1051function sp(id, fromHistory){ 1052 if(IS_RESTRICTED && (id==='investments'||id==='hardmoney')) id='dashboard'; // limited users can't open Investments 1053 curPage=id; 1054 if(typeof hideGlobalSearch==='function') hideGlobalSearch(); 1055 if(!fromHistory){ try{ history.pushState({page:id}, '', '#'+id); }catch(e){} } 1056 var addBtn=ge('btnAdd'); if(addBtn) addBtn.style.display=(id==='contacts')?'':'none'; 1057 document.querySelectorAll('.page').forEach(function(p){p.classList.remove('active');}); 1058 document.querySelectorAll('.ni').forEach(function(n){n.classList.remove('active');}); 1059 var pg=ge('page-'+id);if(pg)pg.classList.add('active'); 1060 var nv=ge('nav-'+id);if(nv)nv.classList.add('active'); 1061 try{ openActiveNavSection(); }catch(e){} // keep the active item's section expanded 1062 ge('pageTitle').textContent=pn[id]||id; 1063 csb(); 1064 if(id==='briefing'){rb();loadBriefingSchedule();loadSendingControls();} 1065 else if(id==='dashboard'){rd();loadBriefingSchedule();loadSendingControls();} 1066 else if(id==='pipeline')rp(); 1067 else if(id==='contacts'){ selectedContacts.clear(); updateBulkBar(); rc(); } 1068 else if(id==='followups')rfu(); 1069 else if(id==='notes')rn(); 1070 else if(id==='investments'){ if(typeof renderInvestments==='function') renderInvestments(); } 1071 else if(id==='hardmoney'){ if(typeof renderHardMoney==='function') renderHardMoney(); } 1072 else if(id==='dealsheet'){ if(typeof renderDealSheets==='function') renderDealSheets(); } 1073 else if(id==='loi'){ if(typeof renderLois==='function') renderLois(); } 1074 else if(id==='listings'){ if(typeof renderListings==='function') renderListings(); } 1075 else if(id==='commercial'){ if(typeof renderCommercial==='function') renderCommercial(); } 1076 else if(id==='newconstruction'){ if(typeof renderNewConstruction==='function') renderNewConstruction(); } 1077 else if(id==='documents')renderDocsPage(); 1078 1079 else if(id==='deadlines'){ 1080 // Populate contact filter 1081 var dlFc = ge('dlFilterContact'); 1082 if(dlFc){ 1083 dlFc.innerHTML='<option value="">All Clients</option>';
1084 C.forEach(function(c){ 1085 var o=document.createElement('option'); 1086 o.value=c.id; o.textContent=fn(c)+' ('+c.type+')'; 1087 dlFc.appendChild(o); 1088 }); 1089 } 1090 dlShowView(dlView); 1091 } 1092 else if(id==='tc')initTC(); 1093 else if(id==='drips'){ processDrips(); renderDrips(); } 1094 else if(id==='scanner')initScanner(); 1095 else if(id==='cardscanner')initCardScanner(); 1096 else if(id==='calendar')initCalendar(); 1097 else if(id==='team')initTeam(); 1098 else if(id==='gmail')initGmail(); 1099 else if(id==='mls')initMLS(); 1100} 1101// Keep the current tab in the URL so Back navigates between tabs and refresh restores the tab. 1102window.addEventListener('popstate', function(e){ 1103 var id = (e.state && e.state.page) || (location.hash||'').replace(/^#/,'') || 'dashboard'; 1104 sp(ge('page-'+id) ? id : 'dashboard', true); 1105}); 1106function restoreTabFromHash(){ 1107 var h = (location.hash||'').replace(/^#/,''); 1108 if(h && ge('page-'+h)){ history.replaceState({page:h}, '', '#'+h); sp(h, true); } 1109 else { history.replaceState({page:(curPage||'dashboard')}, '', '#'+(curPage||'dashboard')); } 1110} 1111function mkRow(cls){var d=document.createElement('div');d.className=cls;return d;} 1112function mkDiv(style,text){var d=document.createElement('div');if(style)d.style.cssText=style;if(text!==undefined)d.textContent=text;return d;} 1113function mkBtn(cls,text,style){var b=document.createElement('button');b.className=cls;b.textContent=text;if(style)b.style.cssText=style;return b;} 1114function mkDot(cls){var d=document.createElement('div');d.className='dot '+cls;return d;} 1115function mkBadge(pri){var s=document.createElement('span');s.className='badge '+pb(pri);s.textContent=pl(pri);return s;} 1116 1117function rb(){ 1118 renderBriefingPriorities(); 1119 var h=new Date().getHours(); 1120 var g=h<12?'Good Morning':h<17?'Good Afternoon':'Good Evening'; 1121 var days=['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; 1122 var months=['January','February','March','April','May','June','July','August','September','October','November','December']; 1123 var now=new Date(); 1124 ge('bDate').textContent=days[now.getDay()]+', '+months[now.getMonth()]+' '+now.getDate()+', '+now.getFullYear(); 1125 ge('bGreet').textContent=g+'!'; 1126 var td=tod(); 1127 var tFU=F.filter(function(f){return !f.done&&f.date===td;}); 1128 var oFU=F.filter(function(f){return !f.done&&f.date<td;}); 1129 var uDL=D.filter(function(d){return du(d.date)<=0 && !dlIsClosed(d) && !dlDonePersonal(d) && !dlDoneStep(d);}); 1130 var wDL=D.filter(function(d){var n=du(d.date);return n>0&&n<=7&&!dlIsClosed(d) && !dlDonePersonal(d) && !dlDoneStep(d);}); 1131 var act=C.filter(function(c){return c.stage && c.stage!=='Closed';}); 1132 var urg=uDL.length+oFU.length; 1133 ge('bUrgNum').textContent=urg; 1134 ge('bTodayNum').textContent=tFU.length; 1135 ge('bActiveNum').textContent=act.length; 1136 var msgs=[]; 1137 if(urg>0)msgs.push(urg+' urgent item'+(urg>1?'s':'')); 1138 if(tFU.length>0)msgs.push(tFU.length+' follow-up'+(tFU.length>1?'s':'')+' due today'); 1139 ge('bSub').textContent=msgs.length?'You have '+msgs.join(' and ')+'.':'No urgent items today. Great work!'; 1140 var uEl=ge('bUrgent');uEl.innerHTML=''; 1141 if(!urg){uEl.innerHTML='<div class="empty" style="padding:14px;">No urgent items!</div>';} 1142 else{ 1143 uDL.forEach(function(d){var c=gc(d.contactId);var n=du(d.date);var row=mkRow('brow');row.appendChild(mkDot('dot-r'));var info=mkRow('brow-info');info.appendChild(mkDiv('font-size:18px;font-weight:600;',d.type));info.appendChild(mkDiv('font-size:18px;color:var(--text3);',c?fn(c):'Unknown'));row.appendChild(info);row.appendChild(mkDiv('font-size:18px;color:var(--danger);',n<0?Math.abs(n)+'d overdue':'TODAY'));uEl.appendChild(row);}); 1144 oFU.forEach(function(f){var c=gc(f.contactId);var row=mkRow('brow');row.appendChild(mkDot('dot-r'));var info=mkRow('brow-info');info.appendChild(mkDiv('font-size:18px;font-weight:600;',f.label));info.appendChild(mkDiv('font-size:18px;color:var(--text3);',c?fn(c):'Unknown'));row.appendChild(info);row.appendChild(mkDiv('font-size:18px;color:var(--danger);',fd(f.date)));uEl.appendChild(row);}); 1145 } 1146 var fEl=ge('bFU');fEl.innerHTML=''; 1147 if(!tFU.length){fEl.innerHTML='<div class="empty" style="padding:14px;">No follow-ups due today.</div>';} 1148 else tFU.forEach(function(f){var c=gc(f.contactId);var row=mkRow('brow');var chk=mkRow('chk'+(f.done?' done':''));if(f.done)chk.appendChild(ck());(function(id){chk.addEventListener('click',function(){tfu(id);rb();});})(f.id);row.appendChild(chk);var info=mkRow('brow-info');info.appendChild(mkDiv('font-size:18px;font-weight:600;',f.label));info.appendChild(mkDiv('font-size:18px;color:var(--text3);',c?fn(c):''));row.appendChild(info);row.appendChild(mkBadge(f.pri));fEl.appendChild(row);}); 1149 var wEl=ge('bWeek');wEl.innerHTML='';
1150 if(!wDL.length){wEl.innerHTML='<div class="empty" style="padding:14px;">No deadlines this week.</div>';} 1151 else wDL.slice().sort(function(a,b){return new Date(a.date)-new Date(b.date);}).forEach(function(d){var c=gc(d.contactId);var n=du(d.date);var row=mkRow('brow');row.appendChild(mkDot(n<=3?'dot-y':'dot-g'));var info=mkRow('brow-info');info.appendChild(mkDiv('font-size:18px;font-weight:600;',d.type));info.appendChild(mkDiv('font-size:18px;color:var(--text3);',c?fn(c):''));row.appendChild(info);row.appendChild(mkDiv('font-size:18px;color:var(--text2);',fd(d.date)+' ('+n+'d)'));wEl.appendChild(row);}); 1152 var sEl=ge('bSnap');sEl.innerHTML=''; 1153 ['New Lead','Contacted','Showing','Under Contract'].forEach(function(s){var cnt=C.filter(function(c){return c.stage===s;}).length;var row=mkRow('brow');var info=mkRow('brow-info');info.appendChild(mkDiv('font-size:18px;font-weight:600;',s));row.appendChild(info);row.appendChild(mkDiv('font-family:monospace;font-size:22px;font-weight:700;color:var(--accent);',cnt));sEl.appendChild(row);}); 1154} 1155 1156function rd(){ 1157 ge('sBuyers').textContent=C.filter(function(c){return ctypes(c).indexOf('buyer')>=0;}).length; 1158 ge('sSellers').textContent=C.filter(function(c){return ctypes(c).indexOf('seller')>=0;}).length; 1159 ge('sLeads').textContent=C.filter(function(c){var t=ctypes(c);return t.indexOf('prospect')>=0||t.indexOf('lead')>=0;}).length; 1160 ge('sClosing').textContent=TX.filter(function(t){return t.status!=='closed';}).length; 1161 var rEl=ge('dRecent');rEl.innerHTML=''; 1162 var rec=A.slice(0,8); 1163 if(!rec.length){rEl.innerHTML='<div class="empty">No activity yet</div>';} 1164 else rec.forEach(function(act){var c=gc(act.contactId);var row=document.createElement('div');row.style.cssText='display:flex;align-items:center;justify-content:space-between;padding:9px 18px;border-bottom:1px solid var(--border);';var nameEl=document.createElement('span');nameEl.style.cssText='font-size:16px;font-weight:600;color:var(--text);';nameEl.textContent=c?fn(c):'Unknown';var typeEl=document.createElement('span');typeEl.style.cssText='font-size:16px;color:var(--text2);';typeEl.textContent=act.type;row.appendChild(nameEl);row.appendChild(typeEl);rEl.appendChild(row);}); 1165 var dlEl=ge('dDeadlines');dlEl.innerHTML=''; 1166 var dl=D.filter(function(d){return !dlIsClosed(d) && !dlDonePersonal(d) && !dlDoneStep(d);}).sort(function(a,b){return new Date(a.date)-new Date(b.date);}).slice(0,5); 1167 if(!dl.length){dlEl.innerHTML='<div class="empty">No deadlines</div>';} 1168 else dl.forEach(function(d){ 1169 var c=gc(d.contactId);var n=du(d.date);var lbl=n<0?'Overdue':n===0?'Today':n+'d'; 1170 var row=mkRow('dl-row');row.style.cursor='pointer';row.addEventListener('click',function(){sp('deadlines');}); 1171 row.appendChild(mkDot(dc(n))); 1172 var info=mkDiv('flex:1;'); 1173 // Deadline type without the trailing "Deadline", plus a small box with the transaction's 1174 // street address (first comma-separated part only, no city/state/zip). 1175 var typeText = String(d.type||'').replace(/\s*deadline\s*$/i,'').trim(); 1176 var tx = dlTxFor(d); 1177 var street = (tx && tx.address) ? String(tx.address).split(',')[0].trim() : ''; 1178 var typeDiv = document.createElement('div'); 1179 typeDiv.style.cssText='font-size:18px;display:flex;align-items:center;gap:7px;flex-wrap:wrap;'; 1180 typeDiv.appendChild(document.createTextNode(typeText)); 1181 if(street){ 1182 var sb=document.createElement('span'); 1183 sb.style.cssText='font-size:13px;color:var(--accent);background:var(--surface2);border:1px solid var(--border);border-radius:5px;padding:1px 8px;white-space:nowrap;'; 1184 sb.textContent=street; 1185 typeDiv.appendChild(sb); 1186 } 1187 info.appendChild(typeDiv); 1188 info.appendChild(mkDiv('font-size:18px;color:var(--text3);',c?fn(c):'')); 1189 row.appendChild(info); 1190 row.appendChild(mkDiv('font-family:monospace;font-size:18px;color:var(--text2);',lbl)); 1191 dlEl.appendChild(row); 1192 }); 1193 var fuEl=ge('dFollowups');fuEl.innerHTML=''; 1194 var pend=F.filter(function(f){return !f.done;}).slice(0,4); 1195 if(!pend.length){fuEl.innerHTML='<div class="empty">All caught up!</div>';}
1196 else pend.forEach(function(f){var c=gc(f.contactId);var row=mkRow('fu-row');row.style.cursor='pointer';row.addEventListener('click',function(){sp('followups');});var chk=mkRow('chk'+(f.done?' done':''));if(f.done)chk.appendChild(ck());(function(id){chk.addEventListener('click',function(e){e.stopPropagation();tfu(id);rd();});})(f.id);var info=mkDiv('flex:1;');info.appendChild(mkDiv('font-size:18px;',f.label));info.appendChild(mkDiv('font-size:18px;color:var(--text3);',(c?fn(c):'')+' - '+fd(f.date)));row.appendChild(chk);row.appendChild(info);row.appendChild(mkBadge(f.pri));fuEl.appendChild(row);}); 1197 var pipEl=ge('dPipeline');pipEl.innerHTML=''; 1198 ['New Lead','Contacted','Showing','Under Contract'].forEach(function(s){var cnt=C.filter(function(c){return c.stage===s;}).length;var row=mkRow('li');row.appendChild(mkDiv('flex:1;font-size:18px;font-weight:600;',s));row.appendChild(mkDiv('font-family:monospace;font-size:22px;font-weight:700;color:var(--accent);',cnt));pipEl.appendChild(row);}); 1199 ge('nbFollowups').textContent=F.filter(function(f){return !f.done;}).length; 1200 ge('nbPipeline').textContent=C.filter(function(c){return c.stage && c.stage!=='Closed';}).length; 1201} 1202 1203function closedRecently(c){ 1204 var CLOSED_DAYS = 5; 1205 var d = c.closedAt || c.closeDate; 1206 if(!d) return false; 1207 var t = new Date(d).getTime(); 1208 if(isNaN(t)) return false; 1209 return (Date.now() - t) <= CLOSED_DAYS * 86400000; 1210} 1211function rp(){ 1212 var board=ge('pBoard');board.innerHTML=''; 1213 var stages=['New Lead','Contacted','Showing','Under Contract','Closed']; 1214 var stageColors={'New Lead':'var(--lead)','Contacted':'var(--buyer)','Showing':'var(--accent)','Under Contract':'var(--seller)','Closed':'var(--text3)'};
1215 stages.forEach(function(s){ 1216 var sc=C.filter(function(c){ 1217 if(c.stage===s && (s!=='Closed' || closedRecently(c))) return true; 1218 // Safety net: an active deal must never fall off the board. If a contact's stage value 1219 // is blank or drifted (not one of the columns) but they have an OPEN transaction, surface 1220 // them under "Under Contract" so the deal can't silently vanish from the pipeline. 1221 if(s==='Under Contract' && stages.indexOf(c.stage)<0 && 1222 TX.some(function(t){return String(t.contactId)===String(c.id) && t.status!=='closed';})) return true; 1223 return false; 1224 }); 1225 var col=document.createElement('div'); 1226 col.style.cssText='min-width:220px;flex-shrink:0;display:flex;flex-direction:column;'; 1227 1228 // Column header 1229 var hdr=mkDiv('display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;padding:0 2px;'); 1230 var htxt=mkDiv('font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;',s); 1231 htxt.style.color=stageColors[s]; 1232 var badge=mkDiv('background:var(--surface3);color:var(--text2);font-size:13px;font-weight:700;padding:2px 8px;border-radius:20px;',sc.length); 1233 hdr.appendChild(htxt);hdr.appendChild(badge);col.appendChild(hdr); 1234 1235 var zone=document.createElement('div'); 1236 zone.style.cssText='flex:1;min-height:80px;padding:2px;'; 1237 1238 sc.forEach(function(c){ 1239 var card=document.createElement('div'); 1240 card.style.cssText='background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:9px;transition:all 0.15s;'; 1241 card.addEventListener('mouseenter',function(){this.style.borderColor='var(--accent)';}); 1242 card.addEventListener('mouseleave',function(){this.style.borderColor='var(--border)';}); 1243 1244 // Type tags 1245 var tgRow=mkDiv('display:flex;gap:4px;flex-wrap:wrap;margin-bottom:5px;'); 1246 ctypes(c).forEach(function(t){ var tg=document.createElement('span');tg.className='tag tag-'+t;tg.style.cssText='font-size:12px;';tg.textContent=t;tgRow.appendChild(tg); }); 1247 if(ctypes(c).length) card.appendChild(tgRow); 1248 1249 // Name â clickable to open detail 1250 var nameEl=mkDiv('font-size:15px;font-weight:600;margin-bottom:2px;cursor:pointer;color:var(--text);',fn(c)); 1251 (function(id){nameEl.addEventListener('click',function(){openEditContact(id);});})(c.id); 1252 nameEl.addEventListener('mouseenter',function(){this.style.color='var(--accent)';}); 1253 nameEl.addEventListener('mouseleave',function(){this.style.color='var(--text)';}); 1254 card.appendChild(nameEl); 1255 1256 if(c.property) card.appendChild(mkDiv('font-size:13px;color:var(--text2);margin-bottom:6px;',c.property)); 1257 1258 var foot=mkDiv('display:flex;align-items:center;justify-content:space-between;margin-top:6px;'); 1259 foot.appendChild(mkDiv('font-family:monospace;font-size:13px;color:var(--accent);',c.price||'')); 1260 1261 // Arrow buttons to move between stages 1262 var arrows=mkDiv('display:flex;gap:4px;'); 1263 var si=stages.indexOf(s); 1264 if(si>0){ 1265 var bL=document.createElement('button'); 1266 bL.style.cssText='background:var(--surface3);border:1px solid var(--accent);border-radius:5px;padding:3px 9px;cursor:pointer;color:var(--accent);font-size:15px;font-weight:700;line-height:1;'; 1267 bL.textContent='â'; 1268 bL.title='Move to '+stages[si-1]; 1269 (function(cid,stage){ bL.addEventListener('click',function(e){ e.stopPropagation(); ss(cid,stage); }); })(c.id, stages[si-1]); 1270 arrows.appendChild(bL); 1271 } 1272 if(si<stages.length-1){ 1273 var bR=document.createElement('button'); 1274 bR.style.cssText='background:var(--surface3);border:1px solid var(--accent);border-radius:5px;padding:3px 9px;cursor:pointer;color:var(--accent);font-size:15px;font-weight:700;line-height:1;'; 1275 bR.textContent='â'; 1276 bR.title='Move to '+stages[si+1]; 1277 (function(cid,stage){ bR.addEventListener('click',function(e){ e.stopPropagation(); ss(cid,stage); }); })(c.id, stages[si+1]); 1278 arrows.appendChild(bR); 1279 } 1280 var bX=document.createElement('button'); 1281 bX.style.cssText='background:var(--surface3);border:1px solid var(--danger);border-radius:5px;padding:3px 9px;cursor:pointer;color:var(--danger);font-size:15px;font-weight:700;line-height:1;'; 1282 bX.textContent='\u00d7'; 1283 bX.title='Remove from pipeline'; 1284 (function(cid){ bX.addEventListener('click',function(e){ e.stopPropagation(); if(confirm('Remove this deal from the pipeline? The contact stays in your contacts.')) ss(cid,''); }); })(c.id); 1285 arrows.appendChild(bX); 1286 foot.appendChild(arrows); 1287 card.appendChild(foot); 1288 zone.appendChild(card); 1289 }); 1290 1291 col.appendChild(zone); 1292 board.appendChild(col); 1293 }); 1294} 1295 1296function updateBulkBar(){ 1297 var bar=ge('bulkBar'); 1298 if(!bar) return; 1299 if(selectedContacts.size>0){ 1300 bar.style.display='flex'; 1301 ge('bulkCount').textContent=selectedContacts.size+' selected'; 1302 } else { 1303 bar.style.display='none'; 1304 } 1305 // show merge button only when exactly 2 selected 1306 var mb=ge('btnMergeContacts'); 1307 if(mb) mb.style.display = selectedContacts.size===2 ? 'inline-flex' : 'none'; 1308} 1309 1310var mergeStep = 1; 1311var mergePreview = null; 1312 1313function mergeMV(a, b, arrKey, scalarKey, split){ 1314 var out = []; 1315 getCMethods(a[arrKey], a[scalarKey], split).forEach(function(x){ 1316 out.push({value: x.value, label: x.label, from: 'A'}); 1317 }); 1318 getCMethods(b[arrKey], b[scalarKey], split).forEach(function(x){ 1319 var dup = false;
1320 for(var i = 0; i < out.length; i++){ 1321 if(out[i].value.toLowerCase() === x.value.toLowerCase()){ dup = true; break; } 1322 } 1323 if(!dup) out.push({value: x.value, label: x.label, from: 'B'}); 1324 }); 1325 return out; 1326} 1327 1328function mergeNotesText(na, nb){ 1329 var segs = []; 1330 [na, nb].forEach(function(t){ 1331 String(t || '').split('|').forEach(function(seg){ 1332 seg = seg.trim(); 1333 if(seg && segs.indexOf(seg) < 0) segs.push(seg); 1334 }); 1335 }); 1336 return segs.join(' | '); 1337} 1338 1339function openMergeModal(){ 1340 var ids=Array.from(selectedContacts); 1341 if(ids.length!==2) return; 1342 var a=gc(ids[0]), b=gc(ids[1]); 1343 if(!a||!b) return; 1344 mergeStep=1; 1345 ge('mergeStepLabel').textContent='Step 1 of 2 â Choose which values to keep'; 1346 ge('btnConfirmMerge').textContent='Review Merged Contact â'; 1347 ge('btnMergeBack').style.display='none'; 1348 ge('btnConfirmMerge').setAttribute('data-a', ids[0]); 1349 ge('btnConfirmMerge').setAttribute('data-b', ids[1]); 1350 showMergeStep1(a, b); 1351 om('mergeModal'); 1352} 1353 1354function showMergeStep1(a, b){ 1355 var body=ge('mergeModalBody'); body.innerHTML=''; 1356 var nameA=(a.first+' '+a.last).trim()||'Contact A'; 1357 var nameB=(b.first+' '+b.last).trim()||'Contact B'; 1358 1359 var wrap=document.createElement('div'); 1360 wrap.style.cssText='padding:4px 0;'; 1361 1362 // ---- Scalar fields: pick one side ---- 1363 var scalars=[ 1364 {key:'first',label:'First Name'}, 1365 {key:'last',label:'Last Name'}, 1366 {key:'stage',label:'Stage'}, 1367 {key:'price',label:'Price'}, 1368 {key:'closeDate',label:'Closing Date'}, 1369 {key:'whatsapp',label:'WhatsApp'} 1370 ]; 1371 1372 var tbl=document.createElement('table'); 1373 tbl.id='mergePickTable'; 1374 tbl.style.cssText='width:100%;border-collapse:collapse;font-size:14px;'; 1375 var thead=document.createElement('thead'); 1376 var hr=document.createElement('tr'); 1377 hr.style.background='var(--surface2)'; 1378 ['Field',nameA,nameB].forEach(function(h,i){ 1379 var th=document.createElement('th'); 1380 th.style.cssText='padding:10px 12px;text-align:left;color:var(--text3);font-weight:600;font-size:13px;'+(i>0?'cursor:pointer;color:var(--accent);':''); 1381 th.textContent=h+(i>0?' (select all)':''); 1382 if(i===1){ th.addEventListener('click',function(){ tbl.querySelectorAll('input[data-side="a"]').forEach(function(r){r.checked=true;}); }); } 1383 if(i===2){ th.addEventListener('click',function(){ tbl.querySelectorAll('input[data-side="b"]').forEach(function(r){r.checked=true;}); }); } 1384 hr.appendChild(th); 1385 }); 1386 thead.appendChild(hr); tbl.appendChild(thead); 1387 1388 var tbody=document.createElement('tbody'); 1389 scalars.forEach(function(f){ 1390 var va=a[f.key]||'', vb=b[f.key]||''; 1391 if(!va&&!vb) return; 1392 var tr=document.createElement('tr'); 1393 tr.style.borderBottom='1px solid var(--border)'; 1394 var tdl=document.createElement('td'); 1395 tdl.style.cssText='padding:9px 12px;color:var(--text3);font-size:13px;font-weight:600;white-space:nowrap;width:100px;'; 1396 tdl.textContent=f.label; tr.appendChild(tdl); 1397 [['a',va],['b',vb]].forEach(function(side){ 1398 var td=document.createElement('td'); td.style.cssText='padding:8px 12px;'; 1399 var lbl=document.createElement('label'); 1400 lbl.style.cssText='display:flex;align-items:flex-start;gap:8px;cursor:pointer;'; 1401 var radio=document.createElement('input'); 1402 radio.type='radio'; radio.name='merge_'+f.key; radio.value=side[0]; 1403 radio.setAttribute('data-side',side[0]); radio.setAttribute('data-key',f.key); 1404 radio.style.cssText='margin-top:3px;flex-shrink:0;accent-color:var(--accent);'; 1405 if(side[0]==='a'&&va) radio.checked=true; 1406 else if(side[0]==='b'&&vb&&!va) radio.checked=true; 1407 var span=document.createElement('span'); 1408 span.style.cssText='font-size:13px;color:'+(side[1]?'var(--text)':'var(--text3)')+';word-break:break-word;'; 1409 span.textContent=side[1]||'(empty)'; 1410 lbl.appendChild(radio); lbl.appendChild(span); td.appendChild(lbl); tr.appendChild(td); 1411 }); 1412 tbody.appendChild(tr); 1413 }); 1414 tbl.appendChild(tbody); wrap.appendChild(tbl); 1415 1416 // ---- Multi-value fields: keep any/all ---- 1417 function mvSection(title, field, items){ 1418 if(!items.length) return; 1419 var sec=document.createElement('div'); 1420 sec.style.cssText='padding:12px 12px 4px;'; 1421 var h=document.createElement('div'); 1422 h.style.cssText='font-size:13px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;'; 1423 h.textContent=title+' (keep any)'; 1424 sec.appendChild(h);
1425 items.forEach(function(it,idx){ 1426 var lbl=document.createElement('label'); 1427 lbl.style.cssText='display:flex;align-items:center;gap:8px;cursor:pointer;padding:5px 0;border-bottom:1px solid var(--border);'; 1428 var cb=document.createElement('input'); 1429 cb.type='checkbox'; cb.checked=true; 1430 cb.className='mv-pick'; 1431 cb.setAttribute('data-field',field); 1432 cb.setAttribute('data-idx',String(idx)); 1433 cb.style.cssText='flex-shrink:0;accent-color:var(--accent);'; 1434 var span=document.createElement('span'); 1435 span.style.cssText='font-size:13px;color:var(--text);word-break:break-word;flex:1;'; 1436 span.textContent=it.value+(it.label?' ('+it.label+')':''); 1437 var tag=document.createElement('span'); 1438 tag.style.cssText='font-size:11px;font-weight:700;color:var(--text3);flex-shrink:0;'; 1439 tag.textContent=it.from==='A'?nameA:nameB; 1440 lbl.appendChild(cb); lbl.appendChild(span); lbl.appendChild(tag); 1441 sec.appendChild(lbl); 1442 }); 1443 wrap.appendChild(sec); 1444 } 1445 1446 mergePreview={a:a,b:b,mv:{}}; 1447 mergePreview.mv.emails = mergeMV(a,b,'emails','email',true); 1448 mergePreview.mv.phones = mergeMV(a,b,'phones','phone',true); 1449 mergePreview.mv.addresses = mergeMV(a,b,'addresses','property',false); 1450 1451 var tset=[]; 1452 ctypes(a).forEach(function(t){ if(tset.indexOf(t)<0) tset.push(t); }); 1453 ctypes(b).forEach(function(t){ if(tset.indexOf(t)<0) tset.push(t); }); 1454 mergePreview.mv.types = tset.map(function(t){ 1455 return {value:t, label:'', from: ctypes(a).indexOf(t)>=0 ? 'A' : 'B'}; 1456 }); 1457 1458 mvSection('Emails','emails',mergePreview.mv.emails); 1459 mvSection('Phones','phones',mergePreview.mv.phones); 1460 mvSection('Addresses','addresses',mergePreview.mv.addresses); 1461 mvSection('Types','types',mergePreview.mv.types); 1462 1463 // ---- Notes: always combined ---- 1464 var nsec=document.createElement('div'); 1465 nsec.style.cssText='padding:12px;'; 1466 var nh=document.createElement('div'); 1467 nh.style.cssText='font-size:13px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;'; 1468 nh.textContent='Notes (combined, editable next step)'; 1469 nsec.appendChild(nh); 1470 var np=document.createElement('div'); 1471 np.style.cssText='font-size:13px;color:var(--text2);background:var(--surface2);border:1px solid var(--border);border-radius:7px;padding:9px 11px;white-space:pre-wrap;word-break:break-word;'; 1472 np.textContent=mergeNotesText(a.notes,b.notes)||'(none)'; 1473 nsec.appendChild(np); 1474 wrap.appendChild(nsec); 1475 1476 body.appendChild(wrap); 1477} 1478 1479function showMergeStep2(merged){ 1480 var body=ge('mergeModalBody'); body.innerHTML=''; 1481 ge('mergeStepLabel').textContent='Step 2 of 2 - Review and edit before saving'; 1482 ge('btnConfirmMerge').textContent='Save Merged Contact'; 1483 ge('btnMergeBack').style.display='inline-flex'; 1484 1485 var wrap=document.createElement('div'); wrap.style.padding='16px 20px'; 1486 1487 function mfld(labelTxt, inputEl){ 1488 var fg=document.createElement('div'); fg.style.cssText='margin-bottom:12px;'; 1489 var lbl=document.createElement('label'); lbl.className='fl'; lbl.textContent=labelTxt; 1490 fg.appendChild(lbl); fg.appendChild(inputEl); wrap.appendChild(fg); 1491 } 1492 1493 var iFirst=document.createElement('input'); iFirst.className='fi'; iFirst.id='mrg_first'; iFirst.value=merged.first||''; 1494 var iLast=document.createElement('input'); iLast.className='fi'; iLast.id='mrg_last'; iLast.value=merged.last||''; 1495 var iStage=document.createElement('select'); iStage.className='fsel'; iStage.id='mrg_stage'; 1496 [['','-- No stage --'],['New Lead','New Lead'],['Contacted','Contacted'],['Showing','Showing'], 1497 ['Under Contract','Under Contract'],['Closed','Closed']].forEach(function(o){ 1498 var opt=document.createElement('option'); opt.value=o[0]; opt.textContent=o[1]; iStage.appendChild(opt); 1499 }); 1500 iStage.value=merged.stage||''; 1501 var iPrice=document.createElement('input'); iPrice.className='fi'; iPrice.id='mrg_price'; iPrice.value=merged.price||''; 1502 var iClose=document.createElement('input'); iClose.className='fi'; iClose.id='mrg_closedate'; iClose.type='date'; iClose.value=merged.closeDate||''; 1503 var iWa=document.createElement('input'); iWa.className='fi'; iWa.id='mrg_whatsapp'; iWa.value=merged.whatsapp||''; 1504 var iNotes=document.createElement('textarea'); iNotes.className='fi'; iNotes.id='mrg_notes'; iNotes.rows=5; iNotes.value=merged.notes||''; 1505 1506 var nameRow=document.createElement('div'); nameRow.style.cssText='display:flex;gap:10px;margin-bottom:12px;'; 1507 var fg1=document.createElement('div'); fg1.style.flex='1'; 1508 var l1=document.createElement('label'); l1.className='fl'; l1.textContent='First Name'; 1509 fg1.appendChild(l1); fg1.appendChild(iFirst); 1510 var fg2=document.createElement('div'); fg2.style.flex='1'; 1511 var l2=document.createElement('label'); l2.className='fl'; l2.textContent='Last Name'; 1512 fg2.appendChild(l2); fg2.appendChild(iLast); 1513 nameRow.appendChild(fg1); nameRow.appendChild(fg2); 1514 wrap.appendChild(nameRow); 1515 1516 function mvList(title, field, items){ 1517 var fg=document.createElement('div'); fg.style.cssText='margin-bottom:12px;'; 1518 var lbl=document.createElement('label'); lbl.className='fl'; 1519 lbl.textContent=title+' ('+items.length+')'; 1520 fg.appendChild(lbl); 1521 if(!items.length){ 1522 var none=document.createElement('div'); 1523 none.style.cssText='font-size:13px;color:var(--text3);padding:4px 0;'; 1524 none.textContent='(none)'; 1525 fg.appendChild(none); 1526 }
1527 items.forEach(function(it,idx){ 1528 var inp=document.createElement('input'); 1529 inp.className='fi'; inp.value=it.value; 1530 inp.setAttribute('data-mvfield',field); 1531 inp.setAttribute('data-mvidx',String(idx)); 1532 inp.style.marginBottom='5px'; 1533 fg.appendChild(inp); 1534 }); 1535 wrap.appendChild(fg); 1536 } 1537 1538 mvList('Emails','emails',merged.emails||[]); 1539 mvList('Phones','phones',merged.phones||[]); 1540 mvList('Addresses','addresses',merged.addresses||[]); 1541 1542 var tRow=document.createElement('div'); tRow.style.cssText='margin-bottom:12px;'; 1543 var tl=document.createElement('label'); tl.className='fl'; tl.textContent='Types'; 1544 tRow.appendChild(tl); 1545 var tv=document.createElement('div'); 1546 tv.style.cssText='font-size:13px;color:var(--text);padding:4px 0;'; 1547 tv.textContent=(merged.types&&merged.types.length)?merged.types.join(', '):'(none)'; 1548 tRow.appendChild(tv); wrap.appendChild(tRow); 1549 1550 mfld('Stage', iStage); mfld('Price', iPrice); 1551 mfld('Closing Date', iClose); mfld('WhatsApp', iWa); mfld('Notes', iNotes); 1552 body.appendChild(wrap); 1553} 1554 1555function buildMergedFromStep1(a, b){ 1556 var merged=JSON.parse(JSON.stringify(a)); 1557 var bodyEl=ge('mergeModalBody'); 1558 1559 var radios=bodyEl.querySelectorAll('input[type="radio"]:checked'); 1560 radios.forEach(function(r){ 1561 var key=r.getAttribute('data-key'); 1562 var src=r.value==='a'?a:b; 1563 merged[key]=src[key]||''; 1564 }); 1565 1566 var picked={emails:[],phones:[],addresses:[],types:[]}; 1567 bodyEl.querySelectorAll('input.mv-pick:checked').forEach(function(cb){ 1568 var field=cb.getAttribute('data-field'); 1569 var idx=parseInt(cb.getAttribute('data-idx'),10); 1570 var item=mergePreview&&mergePreview.mv[field]?mergePreview.mv[field][idx]:null; 1571 if(item) picked[field].push({value:item.value,label:item.label||''}); 1572 }); 1573 1574 merged.emails=picked.emails; 1575 merged.phones=picked.phones; 1576 merged.addresses=picked.addresses; 1577 merged.email=(picked.emails[0]&&picked.emails[0].value)||''; 1578 merged.phone=(picked.phones[0]&&picked.phones[0].value)||''; 1579 merged.property=(picked.addresses[0]&&picked.addresses[0].value)||''; 1580 merged.types=picked.types.map(function(t){ return t.value; }); 1581 merged.type=merged.types.join('|'); 1582 merged.notes=mergeNotesText(a.notes,b.notes); 1583 return merged; 1584} 1585 1586function confirmMerge(){ 1587 var btn=ge('btnConfirmMerge'); 1588 var idA=parseInt(btn.getAttribute('data-a'))||btn.getAttribute('data-a'); 1589 var idB=parseInt(btn.getAttribute('data-b'))||btn.getAttribute('data-b'); 1590 var a=gc(idA), b=gc(idB); if(!a||!b) return; 1591 1592 if(mergeStep===1){ 1593 var built=buildMergedFromStep1(a,b); 1594 mergePreview.merged=built; 1595 mergeStep=2; 1596 showMergeStep2(built); 1597 return; 1598 } 1599 1600 if(!mergePreview||!mergePreview.merged) return; 1601 var merged=mergePreview.merged; 1602 1603 merged.first=ge('mrg_first').value.trim(); 1604 merged.last=ge('mrg_last').value.trim(); 1605 merged.stage=ge('mrg_stage').value; 1606 merged.price=ge('mrg_price').value.trim(); 1607 merged.closeDate=ge('mrg_closedate').value; 1608 merged.whatsapp=ge('mrg_whatsapp').value.trim(); 1609 merged.notes=ge('mrg_notes').value.trim(); 1610 1611 ['emails','phones','addresses'].forEach(function(field){ 1612 var inputs=ge('mergeModalBody').querySelectorAll('input[data-mvfield="'+field+'"]'); 1613 var out=[]; 1614 inputs.forEach(function(inp){ 1615 var idx=parseInt(inp.getAttribute('data-mvidx'),10); 1616 var v=inp.value.trim(); 1617 if(!v) return; 1618 var lab=(merged[field]&&merged[field][idx]&&merged[field][idx].label)||''; 1619 out.push({value:v,label:lab}); 1620 }); 1621 merged[field]=out; 1622 }); 1623 merged.email=(merged.emails[0]&&merged.emails[0].value)||'';
1624 merged.phone=(merged.phones[0]&&merged.phones[0].value)||''; 1625 merged.property=(merged.addresses[0]&&merged.addresses[0].value)||''; 1626 1627 if(!merged.first&&!merged.last){ alert('Please enter at least a first or last name.'); return; } 1628 1629 N.forEach(function(n){ if(String(n.contactId)===String(idB)) n.contactId=idA; }); 1630 F.forEach(function(f){ if(String(f.contactId)===String(idB)) f.contactId=idA; }); 1631 D.forEach(function(d){ if(String(d.contactId)===String(idB)) d.contactId=idA; }); 1632 1633 var idx=C.findIndex(function(c){ return c.id===idA; }); 1634 if(idx>=0) C[idx]=merged; 1635 updateContact(merged); 1636 if(supaReady){ 1637 dbSave('notes',N.filter(function(n){return n.contactId===idA;})); 1638 dbSave('followups',F.filter(function(f){return f.contactId===idA;})); 1639 dbSave('deadlines',D.filter(function(d){return d.contactId===idA;})); 1640 } 1641 delc(idB, true); 1642 selectedContacts.clear(); updateBulkBar(); 1643 mergePreview=null; 1644 cm('mergeModal'); 1645 setTimeout(function(){ 1646 loadFromDB().then(function(){ rc(); rd(); }); 1647 }, 1500); 1648} 1649async function deleteSelectedContacts(){ 1650 if(!selectedContacts.size) return; 1651 var ids = Array.from(selectedContacts); 1652 if(!confirm('Delete '+ids.length+' contact(s) and all their notes, follow-ups, and deadlines?\n\nThis cannot be undone.')) return; 1653 var btn = ge('btnDeleteSelected'); 1654 function setBtn(t, dis){ if(btn){ btn.textContent=t; btn.disabled=!!dis; } } 1655 setBtn('Deleting 0/'+ids.length+'...', true); 1656 pausePoll(180000); // stop the 30s poll from racing the deletes / resurrecting rows 1657 1658 // Remove locally first so the UI is responsive. 1659 var gone = {}; 1660 ids.forEach(function(i){ gone[String(i)] = true; }); 1661 C = C.filter(function(c){ return !gone[String(c.id)]; }); 1662 N = N.filter(function(n){ return !gone[String(n.contactId)]; }); 1663 F = F.filter(function(f){ return !gone[String(f.contactId)]; }); 1664 D = D.filter(function(d){ return !gone[String(d.contactId)]; }); 1665 sv(); 1666 selectedContacts.clear(); 1667 updateBulkBar(); rc(); rd(); 1668 1669 var res = await bulkDeleteContactsFromDB(ids, function(done, total){ 1670 setBtn('Deleting '+done+'/'+total+'...', true); 1671 }); 1672 setBtn('Delete Selected', false); 1673 1674 // Reload from the DB so the list reflects what actually persisted, not what we assumed. 1675 loadFromDB().then(function(){ 1676 rc(); rd(); 1677 if(res.fail) alert('Delete finished, but '+res.fail+' request(s) failed. Some records may remain - select and delete again.'); 1678 }); 1679} 1680 1681function rc(){ 1682 var filter = curFilter==='all' ? null : curFilter; 1683 var list = filter 1684 ? C.filter(function(c){ 1685 var ts = ctypes(c).map(function(t){ return t==='lead'?'prospect':t; }); 1686 return ts.indexOf(filter) >= 0; 1687 }) 1688 : C.slice(); 1689 var q=ge('searchBox').value.toLowerCase(); 1690 if(q) list=list.filter(function(c){return fn(c).toLowerCase().indexOf(q)>=0||(c.email||'').toLowerCase().indexOf(q)>=0||(c.phone||'').indexOf(q)>=0;}); 1691 // sort 1692 list.sort(function(a,b){ 1693 var ka = curSort==='first' 1694 ? (a.first||'').toLowerCase() + (a.last||'').toLowerCase() 1695 : (a.last||'').toLowerCase() + (a.first||'').toLowerCase(); 1696 var kb = curSort==='first' 1697 ? (b.first||'').toLowerCase() + (b.last||'').toLowerCase() 1698 : (b.last||'').toLowerCase() + (b.first||'').toLowerCase(); 1699 return ka<kb?-1:ka>kb?1:0; 1700 }); 1701 var cntEl=ge('contactCount'); 1702 if(cntEl){ 1703 cntEl.textContent = (list.length === C.length) 1704 ? (C.length + ' contact' + (C.length===1 ? '' : 's')) 1705 : (list.length + ' of ' + C.length); 1706 } 1707 var grid=ge('cGrid');grid.innerHTML=''; 1708 if(!list.length){ 1709 grid.appendChild(mkDiv('padding:24px;text-align:center;color:var(--text3);font-size:16px;','No contacts found.')); 1710 return; 1711 } 1712 // letter dividers based on sort key 1713 var lastLetter=''; 1714 list.forEach(function(c){ 1715 var divKey = curSort==='first' ? (c.first||c.last||'#') : (c.last||c.first||'#'); 1716 var letter = divKey.charAt(0).toUpperCase(); 1717 if(letter!==lastLetter){ 1718 lastLetter=letter; 1719 grid.appendChild(mkDiv('padding:8px 18px 4px;font-size:12px;font-weight:700;letter-spacing:2px;color:var(--text3);border-bottom:1px solid var(--border);background:var(--surface2);',letter)); 1720 } 1721 var row=document.createElement('div'); 1722 var isSelected = selectedContacts.has(c.id); 1723 row.style.cssText='display:flex;align-items:center;gap:10px;padding:7px 16px;border-bottom:1px solid var(--border);cursor:pointer;transition:background 0.12s;'+(isSelected?'background:rgba(201,76,76,0.07);':''); 1724 row.addEventListener('mouseenter',function(){if(!selectedContacts.has(c.id))this.style.background='var(--surface2)';}); 1725 row.addEventListener('mouseleave',function(){if(!selectedContacts.has(c.id))this.style.background='';}); 1726 1727 // checkbox 1728 var chkWrap=document.createElement('div'); 1729 chkWrap.style.cssText='width:18px;height:18px;flex-shrink:0;display:flex;align-items:center;justify-content:center;'; 1730 var chk=document.createElement('input'); 1731 chk.type='checkbox'; chk.style.cssText='width:15px;height:15px;cursor:pointer;accent-color:var(--danger);'; 1732 chk.checked=isSelected; 1733 (function(cid,r,cb){ 1734 chk.addEventListener('change',function(e){ 1735 e.stopPropagation(); 1736 if(cb.checked){ selectedContacts.add(cid); r.style.background='rgba(201,76,76,0.07)'; } 1737 else { selectedContacts.delete(cid); r.style.background=''; } 1738 updateBulkBar(); 1739 }); 1740 r.addEventListener('click',function(e){ 1741 if(e.target===cb) return; 1742 if(selectedContacts.size>0){ 1743 e.preventDefault(); 1744 if(selectedContacts.has(cid)){ selectedContacts.delete(cid); cb.checked=false; r.style.background=''; } 1745 else { selectedContacts.add(cid); cb.checked=true; r.style.background='rgba(201,76,76,0.07)'; } 1746 updateBulkBar(); 1747 } else { vc(cid); } 1748 }); 1749 })(c.id, row, chk); 1750 chkWrap.appendChild(chk); row.appendChild(chkWrap); 1751 1752 // name only â clean compact row 1753 var displayName = curSort==='first' ? fn(c) : ((c.last||'') + (c.first ? ', '+c.first : '')); 1754 var nameEl = mkDiv('flex:1;min-width:0;font-size:15px;font-weight:600;color:var(--text);', displayName); 1755 row.appendChild(nameEl); 1756 grid.appendChild(row); 1757 }); 1758} 1759 1760function waLink(v){ 1761 v = String(v).trim(); 1762 var digits = v.replace(/[^\d]/g,''); 1763 if(/^[+]?[\d\s().\-]+$/.test(v) && digits.length >= 7){ 1764 if(digits.length === 10) digits = '1' + digits; 1765 return 'https://wa.me/' + digits; 1766 } 1767 return 'https://wa.me/' + v.replace(/^@/,''); 1768} 1769function getCMethods(arr, fallback, split){ 1770 var list = Array.isArray(arr) ? arr.slice() : []; 1771 list = list.map(function(it){ return (typeof it==='string')?{value:it,label:''}:{value:(it&&it.value)||'',label:(it&&it.label)||''}; }).filter(function(it){ return it.value; }); 1772 if(!list.length && fallback){ 1773 if(split){ String(fallback).split(/\s*[;
1773,\/|]\s*/).forEach(function(v){ v=v.trim(); if(v) list.push({value:v,label:''}); }); } 1774 else { list = [{value:String(fallback),label:''}]; } 1775 } 1776 return list; 1777} 1778function mkContactRow(typeLabel, value, label, actions){ 1779 var row = document.createElement('div'); 1780 row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 0;flex-wrap:wrap;border-bottom:1px solid var(--border);'; 1781 var info = document.createElement('div'); 1782 info.style.cssText = 'flex:1;min-width:140px;'; 1783 var top = document.createElement('div'); 1784 top.style.cssText = 'font-size:18px;color:var(--text);'; top.textContent = value; 1785 var sub = document.createElement('div'); 1786 sub.style.cssText = 'font-size:14px;color:var(--text3);'; sub.textContent = typeLabel + (label ? ' - ' + label : ''); 1787 info.appendChild(top); info.appendChild(sub); row.appendChild(info); 1788 var btns = document.createElement('div'); 1789 btns.style.cssText = 'display:flex;gap:6px;'; 1790 actions.forEach(function(a){ 1791 var el = document.createElement('a'); 1792 el.href = a.href; el.className = 'btn btn-g'; 1793 el.style.cssText = 'font-size:16px;padding:4px 10px;'; el.textContent = a.txt; 1794 btns.appendChild(el); 1795 }); 1796 row.appendChild(btns); return row; 1797} 1798function vc(id){ 1799 curDet=id;var c=gc(id);if(!c)return; 1800 var stgs=['New Lead','Contacted','Showing','Under Contract','Closed']; 1801 var hdr=ge('detHdr');hdr.innerHTML=''; 1802 var av=mkDiv('det-av av-'+ctype1(c),ini(c));av.className='det-av av-'+ctype1(c); 1803 var nd=mkDiv('');nd.appendChild(mkDiv('det-name',fn(c)));nd.children[0].className='det-name'; 1804 var mt=mkDiv('det-meta','');mt.className='det-meta'; 1805 ctypes(c).forEach(function(t){ var tg=document.createElement('span');tg.className='tag tag-'+t;tg.style.marginRight='4px';tg.textContent=t;mt.appendChild(tg); }); 1806 mt.appendChild(document.createTextNode(' Added '+rt(c.added)));nd.appendChild(mt); 1807 var xb=mkBtn('det-x','\u2715','');xb.addEventListener('click',cd); 1808 hdr.appendChild(av);hdr.appendChild(nd);hdr.appendChild(xb); 1809 var acts=ge('detActs');acts.innerHTML=''; 1810 1811 var fb=mkBtn('btn btn-g','+ FU','font-size:18px;padding:5px 11px;');(function(cid){fb.addEventListener('click',function(){ofc(cid);});})(id);acts.appendChild(fb); 1812 var db=mkBtn('btn btn-g','+ DL','font-size:18px;padding:5px 11px;');(function(cid){db.addEventListener('click',function(){odc(cid);});})(id);acts.appendChild(db); 1813 var camb=mkBtn('btn btn-g','+ Campaign','font-size:18px;padding:5px 11px;');(function(cid){camb.addEventListener('click',function(){ if(!CAMP.length){ alert('No campaigns yet. Create one in the Drip Campaigns tab first.'); return; } openEnrollModal(null, cid); });})(id);acts.appendChild(camb); 1814 var delb=mkBtn('btn btn-d','Delete','font-size:18px;padding:5px 11px;margin-left:auto;');(function(cid){delb.addEventListener('click',function(){delc(cid);});})(id);acts.appendChild(delb); 1815 var body=ge('detBody');body.innerHTML=''; 1816 var isec=mksec('Contact Info'); 1817 getCMethods(c.phones, c.phone, true).forEach(function(p){ isec.appendChild(mkContactRow('Phone', p.value, p.label, [{txt:'Call',href:'tel:'+p.value},{txt:'Text',href:'sms:'+p.value}])); }); 1818 getCMethods(c.emails, c.email, true).forEach(function(e){ isec.appendChild(mkContactRow('Email', e.value, e.label, [{txt:'Email',href:'mailto:'+e.value}])); }); 1819 getCMethods(c.addresses, c.property, false).forEach(function(a){ isec.appendChild(mkContactRow('Address', a.value, a.label, [])); }); 1820 if(c.whatsapp){ isec.appendChild(mkContactRow('WhatsApp', c.whatsapp, '', [{txt:'WhatsApp',href:waLink(c.whatsapp)}])); } 1821 if(c.price)isec.appendChild(mkfld('Price',c.price,null,'color:var(--accent);font-family:monospace;')); 1822 if(c.notes)isec.appendChild(mkfld('Notes',c.notes,null,'')); 1823 if(c.closeDate)isec.appendChild(mkfld('Closing Date',fd(c.closeDate),null,'color:var(--accent);')); 1824 body.appendChild(isec); 1825 var tsec=mksec('Type');var tr=mkRow('stage-row');tr.style.flexWrap='wrap'; 1826 [['prospect','Prospect'],['buyer','Buyer'],['seller','Seller'],['agent','Agent'],['lender','Lender'],['title','Title'],['vendor','Vendor'],['other','Other']].forEac
1826h(function(pair){ 1827 var t=pair[0],lbl=pair[1]; 1828 var ts=ctypes(c); 1829 var b=mkBtn('sp'+(ts.indexOf(t)>=0?' active':''),lbl,''); 1830 (function(cid,type){b.addEventListener('click',function(){ 1831 var ct=gc(cid);if(!ct)return; 1832 var arr=ctypes(ct).slice(); 1833 var idx=arr.indexOf(type); 1834 if(idx>=0) arr.splice(idx,1); else arr.push(type); 1835 ct.types=arr; ct.type=arr.join('|'); 1836 updateContact(ct);vc(cid); 1837 rd();if(curPage==='contacts')rc();if(curPage==='pipeline')rp(); 1838 });})(id,t); 1839 tr.appendChild(b); 1840 }); 1841 tsec.appendChild(tr);body.appendChild(tsec); 1842 // Language preference: set once here, every reminder / campaign email reads it. 1843 var lsec=mksec('Language'); 1844 var lr=mkRow('stage-row'); 1845 [['en','English'],['es','Español']].forEach(function(pair){ 1846 var lv=pair[0], llbl=pair[1]; 1847 var lb=mkBtn('sp'+((clang(c)===lv)?' active':''),llbl,''); 1848 (function(cid,val){ lb.addEventListener('click',function(){ 1849 var ct=gc(cid); if(!ct) return; 1850 ct.lang=val; updateContact(ct); logActivity(cid,'Language set to '+(val==='es'?'Español':'English')); 1851 vc(cid); 1852 }); })(id,lv); 1853 lr.appendChild(lb); 1854 }); 1855 lsec.appendChild(lr);body.appendChild(lsec); 1856 1857 var ssec=mksec('Stage');var sr=mkRow('stage-row'); 1858 stgs.forEach(function(s){ 1859 var b=mkBtn('sp'+(c.stage===s?' active':''),s,''); 1860 (function(cid,stage){b.addEventListener('click',function(){ 1861 var ct=gc(cid);if(!ct)return; 1862 // clicking active stage clears it 1863 var newStage = ct.stage===stage ? '' : stage; 1864 ct.stage=newStage; updateContact(ct); 1865 if(newStage) autoEnrollOnStage(ct,newStage); 1866 vc(cid); rd(); if(curPage==='pipeline')rp(); 1867 });})(id,s); 1868 sr.appendChild(b); 1869 }); 1870 ssec.appendChild(sr);body.appendChild(ssec); 1871 var dlsec=mksec('Deadlines'); 1872 var dlab=mkBtn('','+ Add','background:none;border:none;color:var(--accent);cursor:pointer;font-size:18px;margin-left:8px;'); 1873 (function(cid){dlab.addEventListener('click',function(){odc(cid);});})(id);dlsec.querySelector('.det-sec-title').appendChild(dlab); 1874 var cDL=D.filter(function(d){return d.contactId===id;}).sort(function(a,b){return new Date(a.date)-new Date(b.date);}); 1875 if(!cDL.length)dlsec.appendChild(mkDiv('font-size:18px;color:var(--text3);padding:4px 0;','None yet.')); 1876 else cDL.forEach(function(d){var n=du(d.date);var row=mkRow('dl-row');row.style.padding='7px 0';row.appendChild(mkDot(dc(n)));row.appendChild(mkDiv('flex:1;font-size:18px;',d.type));row.appendChild(mkDiv('font-family:monospace;font-size:18px;color:var(--text2);',fd(d.date)));dlsec.appendChild(row);}); 1877 body.appendChild(dlsec); 1878 var fusec=mksec('Follow-ups'); 1879 var fuab=mkBtn('','+ Add','background:none;border:none;color:var(--accent);cursor:pointer;font-size:18px;margin-left:8px;'); 1880 (function(cid){fuab.addEventListener('click',function(){ofc(cid);});})(id);fusec.querySelector('.det-sec-title').appendChild(fuab); 1881 var cFU=F.filter(function(f){return f.contactId===id;}).sort(function(a,b){return new Date(a.date)-new Date(b.date);}); 1882 if(!cFU.length)fusec.appendChild(mkDiv('font-size:18px;color:var(--text3);padding:4px 0;','None yet.')); 1883 else cFU.forEach(function(f){var row=mkRow('det-fu');var chk=mkRow('det-fu-chk'+(f.done?' done':''));if(f.done)chk.appendChild(ck());(function(fid,cid){chk.addEventListener('click',function(){tfu(fid);vc(cid);});})(f.id,id);var lbl=mkDiv('flex:1;font-size:18px;'+(f.done?'text-decoration:line-through;color:var(--text3);':''),f.label);var dt=mkDiv('font-size:18px;color:var(--text3);',fd(f.date));row.appendChild(chk);row.appendChild(lbl);row.appendChild(dt);fusec.appendChild(row);}); 1884 body.appendChild(fusec); 1885 var txsec=mksec('Transactions'); 1886 var cTX=TX.filter(function(t){return t.contactId===id;}); 1887 if(!cTX.length)txsec.appendChild(mkDiv('font-size:18px;color:var(--text3);padding:4px 0;','None yet.')); 1888 else { 1889 // Repeat clients accumulate deals over the years. Show what's live now, then the history 1890 // underneath, so "what are we working on" and "what have we done" are both answerable here. 1891 var actTX=cTX.filter(function(t){ return t.status!=='closed'; }); 1892 var pastTX=cTX.filter(function(t){ return t.status==='closed'; }); 1893 var byDateDesc=function(a,b){ var x=String(a.closingDate||''), y=String(b.closingDate||''); return x<y?1:(x>y?-1:0); }; 1894 actTX.sort(byDateDesc); pastTX.sort(byDateDesc); 1895 var txRow=function(t, isPast){ 1896 var trow=document.createElement('div'); 1897 trow.style.cssText='display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--border);cursor:pointer;'+(isPast?'opacity:0.6;':''); 1898 var tnm=mkDiv('flex:1;font-size:18px;color:'+(isPast?'var(--text2)':'var(--accent)')+';',(t.address||'Transaction')+(t.type?' ('+t.type+')':'')); 1899 var bits=[]; 1900 if(t.price) bits.push(t.price); 1901 if(t.closingDate) bits.push((isPast?'Closed ':'Close ')+fd(t.closingDate)); 1902 var tst=mkDiv('font-size:16px;color:var(--text3);white-space:nowrap;', bits.join(' - ')); 1903 trow.appendChild(tnm);trow.appendChild(tst); 1904 (function(txId){ trow.addEventListener('click',function(){ cd(); openTCDetail(txId); }); })(t.id); 1905 return trow; 1906 };
1907 if(actTX.length) actTX.forEach(function(t){ txsec.appendChild(txRow(t,false)); }); 1908 else txsec.appendChild(mkDiv('font-size:16px;color:var(--text3);padding:4px 0;','No active deals.')); 1909 if(pastTX.length){ 1910 txsec.appendChild(mkDiv('font-size:14px;text-transform:uppercase;letter-spacing:1px;color:var(--text3);margin:10px 0 2px;','Past deals ('+pastTX.length+')')); 1911 pastTX.forEach(function(t){ txsec.appendChild(txRow(t,true)); }); 1912 } 1913 } 1914 body.appendChild(txsec); 1915 // Campaigns this contact is in. The Drips tab has a global list; this answers the question 1916 // you actually ask - "is this person getting emails from us right now?" 1917 var casec=mksec('Campaigns'); 1918 var cEnr=ENR.filter(function(e){ return String(e.contactId)===String(id); }); 1919 var actEnr=cEnr.filter(function(e){ return e.status==='active'; }); 1920 var pastEnr=cEnr.filter(function(e){ return e.status!=='active'; }); 1921 if(!cEnr.length) casec.appendChild(mkDiv('font-size:18px;color:var(--text3);padding:4px 0;','Not in any campaign.')); 1922 else { 1923 var enrRow=function(e, isActive){ 1924 var ecamp=CAMP.find(function(x){ return String(x.id)===String(e.campaignId); }); 1925 var etotal=(ecamp&&ecamp.steps)?ecamp.steps.length:0; 1926 var er=document.createElement('div'); 1927 er.style.cssText='display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--border);'+(isActive?'':'opacity:0.6;'); 1928 var einf=mkDiv('flex:1;'); 1929 einf.appendChild(mkDiv('font-size:18px;color:'+(isActive?'var(--accent)':'var(--text2)')+';', ecamp?ecamp.name:'(deleted campaign)')); 1930 var esub = isActive 1931 ? ('step '+(e.stepIndex+1)+' of '+etotal+' - next: '+(e.nextSendAt?fmtDripWhen(e.nextSendAt):'-')) 1932 : (e.status+(e.stepIndex?(' after step '+e.stepIndex):'')); 1933 einf.appendChild(mkDiv('font-size:16px;color:var(--text3);margin-top:2px;', esub)); 1934 er.appendChild(einf); 1935 if(isActive){ 1936 var esb=mkBtn('btn btn-g','Stop','font-size:15px;padding:4px 10px;color:var(--danger);'); 1937 (function(eid,cid2){ esb.addEventListener('click',function(){ 1938 if(!confirm('Stop this campaign for this contact? No further emails will be sent.')) return; 1939 stopEnrollment(eid); vc(cid2); 1940 }); })(e.id,id); 1941 er.appendChild(esb); 1942 } 1943 return er; 1944 }; 1945 actEnr.forEach(function(e){ casec.appendChild(enrRow(e,true)); }); 1946 if(pastEnr.length){ 1947 casec.appendChild(mkDiv('font-size:14px;text-transform:uppercase;letter-spacing:1px;color:var(--text3);margin:10px 0 2px;','Past ('+pastEnr.length+')')); 1948 pastEnr.forEach(function(e){ casec.appendChild(enrRow(e,false)); }); 1949 } 1950 } 1951 body.appendChild(casec); 1952 var docsec=mksec('Documents'); 1953 var docab=mkBtn('','+ Upload','background:none;border:none;color:var(--accent);cursor:pointer;font-size:18px;margin-left:8px;'); 1954 (function(cid){docab.addEventListener('click',function(){ openDocUpload({contact_id:cid}, function(){ vc(cid); }); });})(id); 1955 docsec.querySelector('.det-sec-title').appendChild(docab); 1956 var cDocs=docsForContact(id); 1957 if(!cDocs.length)docsec.appendChild(mkDiv('font-size:18px;color:var(--text3);padding:4px 0;','None yet.')); 1958 else cDocs.forEach(function(doc){ docsec.appendChild(buildDocRow(doc, function(){ vc(id); })); }); 1959 body.appendChild(docsec); 1960 var nsec=mksec('Notes'); 1961 var cN=N.filter(function(n){return n.contactId===id;}).sort(function(a,b){return new Date(b.date)-new Date(a.date);}); 1962 if(!cN.length)nsec.appendChild(mkDiv('font-size:18px;color:var(--text3);','No notes yet. Add one below.')); 1963 else cN.forEach(function(n){ 1964 var d=mkRow('det-note'); 1965 d.appendChild(mkDiv('det-note-time',new Date(n.date).toLocaleString('en-US',{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}))); 1966 d.children[0].className='det-note-time'; 1967 var t=mkDiv('det-note-text',n.text);t.className='det-note-text';d.appendChild(t); 1968 var delbtn=document.createElement('button'); 1969 delbtn.style.cssText='background:none;border:none;color:var(--text3);cursor:pointer;font-size:18px;line-height:1;flex-shrink:0;padding:0 4px;'; 1970 delbtn.textContent='x'; 1971 delbtn.title='Delete note'; 1972 (function(nid,cid){delbtn.addEventListener('click',function(){ 1973 if(!confirm('Delete this note?')) return; 1974 N=N.filter(function(x){return x.id!==nid;}); 1975 sv(); 1976 pausePoll(8000); deleteNotefromDB(nid); 1977 vc(cid); 1978 });})(n.id, id); 1979 d.appendChild(delbtn); 1980 nsec.appendChild(d); 1981 }); 1982 body.appendChild(nsec); 1983 1984 // Assign To section 1985 var assignSec = document.createElement('div'); 1986 assignSec.style.cssText = 'padding:12px 20px;border-top:1px solid var(--border);display:flex;align-items:center;gap:10px;'; 1987 var assignLbl = document.createElement('div'); 1988 assignLbl.style.cssText = 'font-size:18px;color:var(--text3);flex-shrink:0;'; 1989 assignLbl.textContent = 'Assigned To:'; 1990 var assignSel = document.createElement('select'); 1991 assignSel.className = 'fsel assign-to-select'; 1992 assignSel.style.cssText = 'font-size:18px;padding:5px 8px;flex:1;'; 1993 assignSel.innerHTML = '<option value="">Unassigned</option>'; 1994 TM.forEach(function(m){ 1995 var o=document.createElement('option'); 1996 o.value=m.id; o.textContent=m.first+' '+m.last; 1997 assignSel.appendChild(o); 1998 }); 1999 assignSel.value = c.assignedTo||''; 2000 (function(cid, sel){ assignSel.addEventListener('change', function(){ 2001 var ct=gc(cid); if(!ct) return; 2002 ct.assignedTo = parseInt(sel.value)||null; 2003 updateContact(ct); 2004 // Open member's calendar if assigned 2005 if(ct.assignedTo){ 2006 var m=TM.find(function(x){return x.id===ct.assignedTo;}); 2007 if(m&&m.calLink) assignSec.querySelector('.cal-quick-link') && (assignSec.querySelector('.cal-quick-link').style.display='flex'); 2008 } 2009 }); })(id, assignSel); 2010 assignSec.appendChild(assignLbl); assignSec.appendChild(assignSel); 2011 2012 // Quick calendar link next to assign 2013 var calQuick = document.createElement('div'); 2014 var assignedM = c.assignedTo ? TM.find(function(x){return x.id===c.assignedTo;}) : null; 2015 if(assignedM && assignedM.calLink){ 2016 var calA = document.createElement('a'); 2017 calA.href = assignedM.calLink; 2018 calA.target = '_blank'; 2019 calA.className = 'team-cal-btn cal-quick-link'; 2020 calA.style.cssText = 'width:auto;padding:5px 10px;font-size:18px;flex-shrink:0;'; 2021 calA.innerHTML = '<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> Calendar'; 2022 calQuick.appendChild(calA); 2023 } 2024 assignSec.appendChild(calQuick); 2025 body.appendChild(assignSec); 2026 2027 // Edit / Delete actions 2028 var actSec = document.createElement('div'); 2029 actSec.style.cssText = 'padding:16px 20px;border-top:1px solid var(--border);display:flex;gap:10px;flex-wrap:wrap;'; 2030 var editCBtn = document.createElement('button'); 2031 editCBtn.className = 'btn btn-g'; 2032 editCBtn.textContent = 'Edit Contact'; 2033 (function(cid){ editCBtn.addEventListener('click', function(){ openEditContact(cid); }); })(id); 2034 var delCBtn = document.createElement('button'); 2035 delCBtn.className = 'btn'; 2036 delCBtn.style.cssText = 'background:rgba(201,76,76,0.15);color:var(--danger);border:1px solid rgba(201,76,76,0.3);border-radius:7px;padding:8px 14px;cursor:pointer;font-size:18px;font-family:DM Sans,sans-serif;'; 2037 delCBtn.textContent = 'Delete Contact'; 2038 (function(cid){ delCBtn.addEventListener('click', function(){ delc(cid); }); })(id); 2039 actSec.appendChild(editCBtn); actSec.appendChild(delCBtn); 2040 body.appendChild(actSec); 2041
2042 ge('detOv').classList.add('open'); 2043} 2044function mksec(title){var s=mkRow('det-sec');var t=mkDiv('det-sec-title',title);t.className='det-sec-title';s.appendChild(t);return s;} 2045function mkfld(lbl,val,href,style){var r=mkRow('det-field');var l=mkDiv('det-flbl',lbl);l.className='det-flbl';var v=mkDiv('det-fval','');v.className='det-fval';if(style)v.style.cssText=style;if(href){var a=document.createElement('a');a.href=href;a.textContent=val;v.appendChild(a);}else v.textContent=val;r.appendChild(l);r.appendChild(v);return r;} 2046function cd(){ge('detOv').classList.remove('open');curDet=null;} 2047function ss(id,stage){var c=gc(id);if(!c)return;c.stage=stage;if(stage==='Closed')c.closedAt=new Date().toISOString();updateContact(c);logActivity(id,'Pipeline update');autoEnrollOnStage(c,stage);rd();if(curPage==='pipeline')rp();if(curPage==='contacts')rc();} 2048function delc(id, silent){ 2049 // Silent path (used by merge): immediate, no dialog. Unchanged behavior. 2050 if(silent){ 2051 C=C.filter(function(c){return c.id!==id;}); 2052 N=N.filter(function(n){return n.contactId!==id;}); 2053 F=F.filter(function(f){return f.contactId!==id;}); 2054 D=D.filter(function(d){return d.contactId!==id;}); 2055 sv(); deleteCfromDB(id); 2056 return; 2057 } 2058 var c=gc(id); 2059 var cTx =TX.filter(function(t){ return String(t.contactId)===String(id); }); 2060 var cDocs=DOCS.filter(function(dc){ return String(dc.contact_id)===String(id); }); 2061 var cDl =D.filter(function(d){ return String(d.contactId)===String(id); }); 2062 var cFu =F.filter(function(f){ return String(f.contactId)===String(id); }); 2063 var cNt =N.filter(function(n){ return String(n.contactId)===String(id); }); 2064 var cascades=[]; 2065 if(cTx.length) cascades.push({id:'tx', label: cTx.length+' transaction'+(cTx.length===1?'':'s')}); 2066 if(cDocs.length) cascades.push({id:'docs', label: cDocs.length+' document'+(cDocs.length===1?'':'s')}); 2067 if(cDl.length) cascades.push({id:'deadlines', label: cDl.length+' deadline'+(cDl.length===1?'':'s')}); 2068 if(cFu.length) cascades.push({id:'followups', label: cFu.length+' follow-up'+(cFu.length===1?'':'s')}); 2069 if(cNt.length) cascades.push({id:'notes', label: cNt.length+' note'+(cNt.length===1?'':'s')}); 2070 2071 openDeleteConfirm({ 2072 title: 'Delete contact' + (c?(' â '+fn(c)):''), 2073 warnLine: 'Deleting this contact is permanent and cannot be undone.', 2074 cascades: cascades, 2075 onConfirm: async function(del){ 2076 C=C.filter(function(x){ return x.id!==id; }); 2077 pausePoll(20000); 2078 dbDeleteBy('contacts','id',id); 2079 if(del.notes){ N=N.filter(function(n){ return String(n.contactId)!==String(id); }); dbDeleteBy('notes','contactId',id); } 2080 else cNt.forEach(function(n){ n.contactId=null; saveNote(n); }); 2081 if(del.deadlines){ D=D.filter(function(d){ return String(d.contactId)!==String(id); }); dbDeleteBy('deadlines','contactId',id); } 2082 else cDl.forEach(function(d){ d.contactId=null; saveDL(d); }); 2083 if(del.followups){ F=F.filter(function(f){ return String(f.contactId)!==String(id); }); dbDeleteBy('followups','contactId',id); } 2084 else cFu.forEach(function(f){ f.contactId=null; saveFU(f); }); 2085 if(del.tx){ cTx.forEach(function(t){ dbDeleteBy('transactions','id',t.id); dbDeleteBy('tx_changes','transactionId',t.id); CH=CH.filter(function(h){ return String(h.transactionId)!==String(t.id); }); }); TX=TX.filter(function(t){ return String(t.contactId)!==String(id); }); } 2086 else cTx.forEach(function(t){ t.contactId=null; saveTX(t); }); 2087 if(del.docs && cDocs.length){ try{ await bulkDeleteDocuments(cDocs); }catch(e){} } 2088 else cDocs.forEach(function(dc){ dc.contact_id=null; if(supaReady) dbSave('documents',[dc]); }); 2089 sv(); cd(); rd(); if(curPage==='contacts')rc(); if(curPage==='pipeline')rp(); 2090 } 2091 }); 2092} 2093 2094function rfu(){ 2095 var el=ge('fuList');el.innerHTML=''; 2096 var sorted=F.slice().sort(function(a,b){return new Date(a.date)-new Date(b.date);}); 2097 if(!sorted.length){el.innerHTML='<div class="empty">No follow-ups yet.</div>';return;} 2098 sorted.forEach(function(f){var c=gc(f.contactId);var row=mkRow('fu-row');var chk=mkRow('chk'+(f.done?' done':''));if(f.done)chk.appendChild(ck());(function(id){chk.addEventListener('click',function(){tfu(id);rfu();});})(f.id);var info=mkDiv('flex:1;');info.appendChild(mkDiv('font-size:18px;',f.label));info.appendChild(mkDiv('font-size:18px;color:var(--text3);',(c?fn(c):'Unknown')+' - '+fd(f.date)));var right=mkDiv('display:flex;align-items:center;gap:7px;');right.appendChild(mkBadge(f.pri));var del=mkBtn('','\u00d7','background:none;border:none;color:var(--text3);cursor:pointer;font-size:18px;line-height:1;');// GCal sync for follow-up 2099 var gcalFuBtn=document.createElement('button'); 2100 gcalFuBtn.style.cssText='background:rgba(76,142,201,0.1);border:1px solid rgba(76,142,201,0.2);border-radius:5px;padding:3px 8px;cursor:pointer;font-size:14px;color:var(--buyer);font-family:DM Sans,sans-serif;'; 2101 gcalFuBtn.textContent='+ GCal'; 2102 gcalFuBtn.title='Add to Google Calendar'; 2103 (function(fu){ gcalFuBtn.addEventListener('click',function(e){ 2104 e.stopPropagation(); 2105 var c6=gc(fu.contactId); 2106 syncToGCal({ 2107 title:fu.label+(c6?' - '+fn(c6):''), 2108 date:fu.date,time:'',type:'followup', 2109 clientName:c6?fn(c6):'', 2110 notes:'Follow-up from Properly CRM' 2111 }, fu.assignedTo); 2112 }); })(f); 2113 right.appendChild(gcalFuBtn); 2114 var editFuBtn=document.createElement('button'); 2115 editFuBtn.style.cssText='background:var(--surface2);border:1px solid var(--border);border-radius:5px;padding:3px 8px;cursor:pointer;font-size:14px;color:var(--text2);font-family:DM Sans,sans-serif;'; 2116 editFuBtn.textContent='Edit'; 2117 (function(fu){ editFuBtn.addEventListener('click',function(e){ e.stopPropagation(); openEditFU(fu); }); })(f); 2118 right.appendChild(editFuBtn); 2119 (function(id){del.addEventListener('click',function(){pausePoll(8000);F=F.filter(function(f){return f.id!==id;});sv();deleteFUfromDB(id);rfu();rd();});})(f.id);right.appendChild(del);row.appendChild(info);row.appendChild(right);el.appendChild(row);}); 2120 ge('nbFollowups').textContent=F.filter(function(f){return !f.done;}).length; 2121} 2122function tfu(id){var f=F.find(function(x){return x.id===id;});if(f){f.done=!f.done;sv();if(supaReady) dbSave('followups',[f]);}} 2123 2124// ---- Notes tab state ---- 2125var _noteSearch='', _noteCat='all', _noteCollapsed={}, _noteAllCollapsed=true, _showArchivedNotes=false, _noteLang={}; 2126// A note is "archived" once its transaction is closed - hidden from the Notes tab unless the 2127// "Archived" checkbox is on. Computed (no stored flag needed). 2128function noteArchived(n){ 2129 if(n.transactionId==null) return false;
2130 var tx = TX.find(function(t){ return String(t.id)===String(n.transactionId); }); 2131 return !!(tx && tx.status==='closed'); 2132} 2133function ncat(n){ return (n && n.category) || 'General'; } 2134function _noteCatColor(cat){ 2135 return cat==='Document' ? 'var(--seller)' : cat==='Call' ? 'var(--buyer)' : 2136 cat==='Meeting' ? 'var(--accent)' : 'var(--text3)'; 2137} 2138// Build one note card (shared header/text/actions + category tag + EN/ES toggle). 2139function _buildNoteCard(note){ 2140 var c=gc(note.contactId); 2141 var d=mkRow('note-item'); d.style.position='relative'; 2142 var h=mkRow('note-hdr'); 2143 var av=mkDiv('av av-'+(c?c.type:'lead'),c?ini(c):'?'); 2144 av.className='av av-'+(c?c.type:'lead'); 2145 av.style.cssText='width:26px;height:26px;font-size:18px;'; 2146 var b=document.createElement('b'); b.style.fontSize='12px'; b.textContent=c?fn(c):'Unknown'; 2147 h.appendChild(av); h.appendChild(b); 2148 if(c){var tg=document.createElement('span');tg.className='tag tag-'+ctype1(c);tg.textContent=ctype1(c);h.appendChild(tg);} 2149 // Category tag 2150 var cat=ncat(note); 2151 var ct=document.createElement('span'); 2152 ct.style.cssText='font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px;padding:2px 8px;border-radius:20px;color:#0d0f14;background:'+_noteCatColor(cat)+';'; 2153 ct.textContent=cat; h.appendChild(ct); 2154 h.appendChild(mkDiv('font-size:18px;color:var(--text3);',rt(note.date))); 2155 var acts=document.createElement('div'); 2156 acts.style.cssText='margin-left:auto;display:flex;gap:6px;flex-shrink:0;align-items:center;'; 2157 // EN/ES toggle (only when a Spanish version exists) 2158 var txt=document.createElement('div'); 2159 txt.className='note-text'; txt.style.whiteSpace='pre-wrap'; txt.style.wordBreak='break-word'; 2160 var _showEs=(_noteLang[note.id]==='es'); 2161 txt.textContent=(_showEs && note.textEs)?note.textEs:note.text; 2162 if(note.textEs){ 2163 var enB=document.createElement('button'); enB.className='nlang-btn'+(_showEs?'':' on'); enB.textContent='EN'; 2164 var esB=document.createElement('button'); esB.className='nlang-btn'+(_showEs?' on':''); esB.textContent='ES'; 2165 // Remember the choice so the 30s background sync re-render doesn't revert it to English. 2166 enB.addEventListener('click',function(){ _noteLang[note.id]='en'; txt.textContent=note.text; enB.classList.add('on'); esB.classList.remove('on'); }); 2167 esB.addEventListener('click',function(){ _noteLang[note.id]='es'; txt.textContent=note.textEs; esB.classList.add('on'); enB.classList.remove('on'); }); 2168 acts.appendChild(enB); acts.appendChild(esB); 2169 } 2170 var editBtn=document.createElement('button'); 2171 editBtn.style.cssText='background:var(--surface2);border:1px solid var(--border);border-radius:5px;padding:3px 8px;cursor:pointer;font-size:18px;color:var(--text2);font-family:DM Sans,sans-serif;'; 2172 editBtn.textContent='Edit'; 2173 (function(n2){ editBtn.addEventListener('click',function(){ openEditNote(n2); }); })(note); 2174 var delBtn=document.createElement('button'); 2175 delBtn.style.cssText='background:rgba(201,76,76,0.1);border:1px solid rgba(201,76,76,0.2);border-radius:5px;padding:3px 8px;cursor:pointer;font-size:18px;color:var(--danger);font-family:DM Sans,sans-serif;'; 2176 delBtn.textContent='Del'; 2177 (function(nid){ delBtn.addEventListener('click',function(){ 2178 if(!confirm('Delete this note?')) return; 2179 N=N.filter(function(x){return x.id!==nid;}); 2180 sv(); pausePoll(8000); deleteNotefromDB(nid); rn(); rd(); 2181 }); })(note.id); 2182 acts.appendChild(editBtn); acts.appendChild(delBtn); 2183 h.appendChild(acts); d.appendChild(h); d.appendChild(txt); 2184 return d; 2185} 2186function rn(){ 2187 var el=ge('nList');el.innerHTML=''; 2188 if(!N.length){el.innerHTML='<div class="empty">No notes yet.</div>';return;} 2189 var q=(_noteSearch||'').trim().toLowerCase(); 2190 // Filter by category + search (text, spanish, client name, category) 2191 var matched=N.filter(function(n){ 2192 if(!_showArchivedNotes && noteArchived(n)) return false; // hide notes from closed deals 2193 if(_noteCat!=='all' && ncat(n)!==_noteCat) return false; 2194 if(!q) return true; 2195 var c=gc(n.contactId); 2196 var hay=((n.text||'')+' '+(n.textEs||'')+' '+ncat(n)+' '+(c?fn(c):'')).toLowerCase(); 2197 return hay.indexOf(q)>=0; 2198 }); 2199 if(!matched.length){el.innerHTML='<div class="empty">No notes match your search.</div>';return;} 2200 // Group by client 2201 var groups={}, order=[]; 2202 matched.forEach(function(n){ 2203 var key=n.contactId?String(n.contactId):'unknown'; 2204 if(!groups[key]){ groups[key]={notes:[], latest:0, contactId:n.contactId}; order.push(key); } 2205 groups[key].notes.push(n); 2206 var t=new Date(n.date).getTime()||0; if(t>groups[key].latest) groups[key].latest=t; 2207 }); 2208 // Most recently active client first 2209 order.sort(function(a,b){ return groups[b].latest-groups[a].latest; }); 2210 order.forEach(function(key){ 2211 var g=groups[key]; 2212 var c=g.contactId?gc(g.contactId):null; 2213 var name=c?fn(c):'Unassigned / Unknown'; 2214 var collapsed=(_noteCollapsed[key]===undefined)?true:!!_noteCollapsed[key]; 2215 var wrap=document.createElement('div'); 2216 wrap.style.cssText='border-bottom:1px solid var(--border);'; 2217 if(collapsed) wrap.className='ngrp-collapsed'; 2218 // Header 2219 var hdr=document.createElement('div'); hdr.className='ngrp-hdr'; 2220 var caret=mkDiv('','â¼'); caret.className='ngrp-caret'; 2221 var nm=mkDiv('font-weight:700;font-size:14px;color:var(--text);',name); 2222 var cnt=mkDiv('',g.notes.length+(g.notes.length===1?' note':' notes')); cnt.className='ngrp-count'; 2223 hdr.appendChild(caret); hdr.appendChild(nm); hdr.appendChild(cnt); 2224 var body=document.createElement('div'); body.style.display=collapsed?'none':''; 2225 g.notes.sort(function(a,b){return new Date(b.date)-new Date(a.date);});
2226 g.notes.forEach(function(n){ body.appendChild(_buildNoteCard(n)); }); 2227 (function(k,bodyEl,wrapEl){ hdr.addEventListener('click',function(){ 2228 var cur=(_noteCollapsed[k]===undefined)?true:!!_noteCollapsed[k]; 2229 var nowCollapsed=!cur; _noteCollapsed[k]=nowCollapsed; 2230 bodyEl.style.display=nowCollapsed?'none':''; wrapEl.classList.toggle('ngrp-collapsed',nowCollapsed); 2231 }); })(key,body,wrap); 2232 wrap.appendChild(hdr); wrap.appendChild(body); el.appendChild(wrap); 2233 }); 2234} 2235 2236// Searchable contact picker for the Note modal (built once, reused). Its hidden input keeps the 2237// id 'nContact', so the save code reads the selected id exactly as before. 2238var _notePicker=null; 2239function ensureNotePicker(){ 2240 if(_notePicker) return _notePicker; 2241 var wrap=ge('nContactWrap'); if(!wrap) return null; 2242 wrap.innerHTML=''; 2243 _notePicker=buildContactPicker(wrap, 'nContact', 'Search contact by name, email, phone...', function(c){ fsDeals('nDeal', c?c.id:'', ''); }); 2244 return _notePicker; 2245} 2246function openEditNote(note){ 2247 // Pre-fill the note modal 2248 var _np=ensureNotePicker(); if(_np) _np.setContact(gc(note.contactId)); 2249 fsDeals('nDeal', note.contactId, note.transactionId); 2250 if(ge('nCategory')) ge('nCategory').value=note.category||'General'; 2251 ge('nText').value=note.text; 2252 ge('btnSaveNote') && ge('btnSaveNote').setAttribute('data-edit-id', note.id); 2253 om('noteModal'); 2254} 2255// Resolve the transaction a deadline belongs to. When a deadline is only tied to a contact, 2256// prefer an OPEN deal - otherwise a contact-level deadline could attach to a closed deal 2257// (and get hidden) while their active deal is the one that actually needs it. 2258// ---- Reminder settings ---- 2259// The cron reads reminder_settings from Supabase, so these must roun
2259d-trip through the DB, 2260// not localStorage. A row with deadlineType = null is the default for every type. 2261var RS_TYPES = ['Earnest Money Due','Due Diligence Deadline','Financing Deadline','Appraisal Deadline', 2262 'Closing Date','Closing / Settlement Date','Inspection Period Ends','Financing Contingency', 2263 'Offer Expiration','Final Walkthrough']; 2264function rsDefault(){ return RS.find(function(r){ return !r.deadlineType; }) || null; } 2265function rsForType(t){ return RS.find(function(r){ return r.deadlineType === t; }) || null; } 2266// "3, 1" -> [3,1]. Ignores junk, drops negatives/dupes, sorts furthest-out first. 2267function rsParseDays(str){ 2268 var out = []; 2269 String(str||'').split(',').forEach(function(p){ 2270 var n = parseInt(String(p).trim(), 10); 2271 if(!isNaN(n) && n >= 0 && out.indexOf(n) === -1) out.push(n); 2272 }); 2273 out.sort(function(a,b){ return b-a; }); 2274 return out; 2275} 2276function rsFmtDays(arr){ return (arr||[]).join(', '); } 2277 2278// Every reminder type known to the app: the standard transaction ones, anything already used 2279// on a deadline, and any custom type saved in reminder_settings. This is also what feeds the 2280// Add Deadline dropdown, so a custom "Lunch with lender" becomes selectable there. 2281function rsAllTypes(){ 2282 // Standard transaction types plus the custom types you've configured (RS rows). 2283 // Deliberately NOT every type used by a deadline - otherwise a type could never be deleted 2284 // because a reminder still referencing the name would keep re-adding it to the list. 2285 var types = RS_TYPES.slice(); 2286 RS.forEach(function(r){ if(r.deadlineType && types.indexOf(r.deadlineType) === -1) types.push(r.deadlineType); }); 2287 return types; 2288} 2289 2290var rsTypeSet = []; // types currently rendered in the modal, indexed so field ids stay simple 2291 2292function rsRenderTypes(){ 2293 var wrap = ge('rsTypeList'); if(!wrap) return; 2294 wrap.innerHTML = ''; 2295 rsTypeSet = rsAllTypes(); 2296 rsTypeSet.forEach(function(t, i){ wrap.appendChild(rsTypeRow(t, i)); }); 2297} 2298 2299function rsTypeRow(t, i){ 2300 var row = rsForType(t); 2301 var isCustom = RS_TYPES.indexOf(t) === -1; 2302 var box = document.createElement('div'); 2303 box.style.cssText = 'border:1px solid var(--border);border-radius:8px;padding:8px 10px;margin-bottom:8px;'; 2304 2305 var top = document.createElement('div'); 2306 top.style.cssText = 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;'; 2307 var nm = mkDiv('flex:1;font-size:15px;font-weight:600;color:var(--text);min-width:130px;', t + (isCustom ? ' ⢠custom' : '')); 2308 top.appendChild(nm); 2309 var daysI = document.createElement('input'); 2310 daysI.className = 'fi'; daysI.id = 'rs_days_' + i; daysI.placeholder = 'default'; 2311 daysI.style.cssText = 'width:90px;font-size:15px;padding:4px 8px;'; daysI.title = 'Days before, e.g. 3, 1'; 2312 daysI.value = row ? rsFmtDays(row.daysBefore) : ''; 2313 top.appendChild(daysI); 2314 var onL = document.createElement('label'); 2315 onL.style.cssText = 'display:flex;align-items:center;gap:5px;font-size:14px;color:var(--text3);cursor:pointer;'; 2316 var onC = document.createElement('input'); onC.type = 'checkbox'; onC.id = 'rs_on_' + i; onC.style.accentColor = 'var(--accent)'; 2317 onC.checked = row ? (row.enabled !== false) : true; 2318 onL.appendChild(onC); onL.appendChild(document.createTextNode('On')); 2319 top.appendChild(onL); 2320 var tgl = mkBtn('btn btn-g', 'Email', 'font-size:14px;padding:4px 9px;'); 2321 var hasTpl = row && (row.subjectEn || row.bodyEn || row.subjectEs || row.bodyEs); 2322 if(hasTpl) tgl.textContent = 'Email â'; 2323 top.appendChild(tgl); 2324 // Only custom types can be deleted; standard transaction types are built in. 2325 if(isCustom){ 2326 var delB = mkBtn('btn btn-d', 'â', 'font-size:14px;padding:4px 9px;'); 2327 delB.title = 'Delete this reminder type'; 2328 (function(tt, rr){ delB.addEventListener('click', function(){ 2329 if(!confirm('Delete the reminder type "' + tt + '"?\n\nExisting reminders already created with this type keep working; you just can\'t pick it for new ones.')) return; 2330 if(rr){ RS = RS.filter(function(r){ return r.id !== rr.id; }); deleteRSfromDB(rr.id); } 2331 rsRenderTypes(); 2332 }); })(t, row); 2333 top.appendChild(delB); 2334 } 2335 box.appendChild(top); 2336 2337 var ed = document.createElement('div'); ed.style.cssText = 'display:none;margin-top:8px;'; 2338 function field(id, ph, val, rows){ 2339 var w = mkDiv('margin-bottom:6px;'); 2340 var el = document.createElement(rows ? 'textarea' : 'input'); 2341 el.className = 'fi'; el.id = id; el.placeholder = ph; 2342 el.style.cssText = 'width:100%;font-size:14px;padding:5px 8px;'; 2343 if(rows) el.rows = rows; 2344 if(val) el.value = val; 2345 w.appendChild(el); return w; 2346 } 2347 ed.appendChild(mkDiv('font-size:13px;color:var(--text3);margin-bottom:4px;', 'English')); 2348 ed.appendChild(field('rs_suben_' + i, 'Subject (blank = automatic wording)', row ? row.subjectEn : '', 0)); 2349 ed.appendChild(field('rs_bodyen_' + i, 'Body (blank = automatic wording)', row ? row.bodyEn : '', 4)); 2350 ed.appendChild(mkDiv('font-size:13px;color:var(--text3);margin:8px 0 4px;', 'Español')); 2351 ed.appendChild(field('rs_subes_' + i, 'Asunto (opcional)', row ? row.subjectEs : '', 0)); 2352 ed.appendChild(field('rs_bodyes_' + i, 'Cuerpo (opcional)', row ? row.bodyEs : '', 4)); 2353 ed.appendChild(mkDiv('font-size:12px;color:var(--text3);margin-top:2px;', 2354 'Tags: {{first}} {{last}} {{property}} {{date}} {{when}} {{type}} {{agent}}')); 2355 box.appendChild(ed); 2356 tgl.addEventListener('click', function(){ ed.style.display = ed.style.display === 'none' ? 'block' : 'none'; }); 2357 return box; 2358} 2359 2360function openReminderSettings(){ 2361 var def = rsDefault(); 2362 ge('rsDefaultDays').value = def ? rsFmtDays(def.daysBefore) : '3, 1'; 2363 ge('rsDefaultEnabled').checked = def ? (def.enabled !== false) : true; 2364 rsRenderTypes(); 2365 om('rsModal'); 2366} 2367 2368// Add a brand-new reminder type on the spot (e.g. "Lunch with lender"). Persisted immediately 2369// so it shows up in the Add Deadline dropdown even before the modal is saved. 2370function rsAddType(){ 2371 var nm = (ge('rsNewType') && ge('rsNewType').value || '').trim(); 2372 if(!nm) return; 2373 if(rsAllTypes().some(function(x){ return x.toLowerCase() === nm.toLowerCase(); })){ 2374 alert('A reminder called "' + nm + '" already exists.'); return; 2375 } 2376 var newRow = { id: Date.now() + Math.floor(Math.random()*100000), deadlineType: nm, 2377 daysBefore: null, enabled: true, updatedAt: new Date().toISOString() };
2378 RS.push(newRow); saveRS(newRow); 2379 if(ge('rsNewType')) ge('rsNewType').value = ''; 2380 rsRenderTypes(); 2381} 2382 2383function saveReminderSettings(){ 2384 var days = rsParseDays(ge('rsDefaultDays').value); 2385 if(!days.length){ alert('Enter at least one number for the default, e.g. 3, 1'); return; } 2386 var def = rsDefault(); 2387 if(!def){ def = { id: 1, deadlineType: null }; RS.push(def); } 2388 def.daysBefore = days; 2389 def.enabled = ge('rsDefaultEnabled').checked; 2390 def.updatedAt = new Date().toISOString(); 2391 saveRS(def); 2392 2393 rsTypeSet.forEach(function(t, i){ 2394 var isCustom = RS_TYPES.indexOf(t) === -1; 2395 var d2 = rsParseDays((ge('rs_days_' + i) || {}).value || ''); 2396 var isOn = (ge('rs_on_' + i) || {}).checked !== false; 2397 var subEn = (((ge('rs_suben_' + i) || {}).value) || '').trim(); 2398 var bodyEn = (((ge('rs_bodyen_' + i) || {}).value) || '').trim(); 2399 var subEs = (((ge('rs_subes_' + i) || {}).value) || '').trim(); 2400 var bodyEs = (((ge('rs_bodyes_' + i) || {}).value) || '').trim(); 2401 var hasTpl = subEn || bodyEn || subEs || bodyEs; 2402 var row = rsForType(t); 2403 2404 // A standard type with no custom timing and no template needs no row - fall back to default. 2405 if(!isCustom && !d2.length && isOn && !hasTpl){ 2406 if(row){ row.daysBefore = null; row.enabled = true; row.subjectEn = row.bodyEn = row.subjectEs = row.bodyEs = null; row.updatedAt = new Date().toISOString(); saveRS(row); } 2407 return; 2408 } 2409 if(!row){ row = { id: Date.now() + Math.floor(Math.random()*100000), deadlineType: t }; RS.push(row); } 2410 row.daysBefore = d2.length ? d2 : null; // null = use the default timing 2411 row.enabled = isOn; 2412 row.subjectEn = subEn || null; row.bodyEn = bodyEn || null; 2413 row.subjectEs = subEs || null; row.bodyEs = bodyEs || null; 2414 row.updatedAt = new Date().toISOString(); 2415 saveRS(row); 2416 }); 2417 cm('rsModal'); 2418 alert('Reminders saved. The daily job uses these on its next run.'); 2419} 2420 2421function dlTxFor(d){ 2422 var tx=null; 2423 if(d.transactionId!=null) tx=TX.find(function(t){ return String(t.id)===String(d.transactionId); }); 2424 if(!tx && d.contactId!=null){ 2425 tx = TX.find(function(t){ return String(t.contactId)===String(d.contactId) && t.status!=='closed'; }) 2426 || TX.find(function(t){ return String(t.contactId)===String(d.contactId); }); 2427 } 2428 return tx||null; 2429} 2430// A closed deal's deadlines are done - they shouldn't linger anywhere. 2431function dlIsClosed(d){ var t=dlTxFor(d); return !!(t && t.status==='closed'); } 2432// Map a transaction-deadline type to the transaction date field it tracks. 2433function _dlTypeToField(type){ 2434 return ({'Earnest Money Due':'earnestDate','Due Diligence Deadline':'dueDiligDate', 2435 'Financing Deadline':'financingDate','Appraisal Deadline':'appraisalDate', 2436 'Closing Date':'closingDate','Closing / Settlement Date':'closingDate'})[type] || null; 2437} 2438// A PAST transaction deadline whose matching checklist step is checked has been handled and 2439// shouldn't keep nagging as overdue. Restricted to overdue dates on purpose: the date-tracked 2440// steps auto-check on import (they mean "tracking", not "done"), so hiding UPCOMING deadlines by 2441// them would wrongly remove live deadlines. Only overdue + checked is safe to hide. 2442function _stepForField(tx, field){ 2443 var tmpl = (typeof TC_TEMPLATES !== 'undefined') ? (TC_TEMPLATES[tx.type] || TC_TEMPLATES['buyer']) : null; 2444 if(!tmpl) return null; 2445 for(var i=0;i<tmpl.length;i++){ var st=tmpl[i].steps||[]; for(var j=0;j<st.length;j++){ if(st[j].dateField===field) return st[j]; } } 2446 return null; 2447} 2448function dlDoneStep(d){ 2449 if(!d || d.transactionId==null || !d.date) return false; 2450 var tx = TX.find(function(t){ return String(t.id)===String(d.transactionId); }); 2451 if(!tx) return false; 2452 var field = _dlTypeToField(d.type); if(!field) return false; 2453 var step = _stepForField(tx, field); 2454 // A COMPLETION step (e.g. Appraisal) is a real "done" checkbox: once checked - or once the 2455 // contingency is waived (N/A) - the deadline is finished and hides regardless of its date. 2456 if(step && step.completion){ 2457 // Honor the new key AND the legacy "appraisal completed" keys, so a deal marked done before 2458 // the collapse stays hidden even if the one-time migration hasn't run on this device yet. 2459 var doneKeys = (field==='appraisalDate') ? [step.key,'b3_apprloan','s3_appraisaldone'] : [step.key]; 2460 if(tx.steps && doneKeys.some(function(k){ return tx.steps[k]; })) return true; 2461 if(field==='appraisalDate'){ 2462 var dc = tx.docChecklist || {}; 2463 if(dc.appraisal==='present' || dc.appraisal==='na') return true; // appraisal doc present / N/A 2464 if(typeof appraisalWaived==='function' && appraisalWaived(tx)) return true; 2465 } 2466 return false;
2467 } 2468 // Tracking steps auto-check on import (they mean "tracking", not "done"), so only an OVERDUE 2469 // date is safe to auto-hide when checked - hiding upcoming ones would drop live deadlines. 2470 if(du(d.date) >= 0) return false; 2471 return _dateHandled(tx, field); 2472} 2473 2474// ---- Deadlines page: two subtabs (transaction deadlines vs personal reminders) ---- 2475var dlView = 'transactions'; 2476function personalReminders(){ return D.filter(function(d){ return d.contactId==null && d.transactionId==null; }); } 2477// A personal reminder is "done" once the cron has actually sent it (a reminder_log row), 2478// or once its date has passed (a fallback so unsent past ones don't linger forever). 2479function personalReminderDone(d){ 2480 if(RL.some(function(r){ return String(r.deadlineId)===String(d.id) && r.status==='sent'; })) return true; 2481 if(d.date && d.date < tod()) return true; 2482 return false; 2483} 2484// A personal reminder that's already fired (or is past) shouldn't clutter the dashboard/briefing. 2485// Only applies to personal reminders - an overdue TRANSACTION deadline is still genuinely urgent. 2486function dlDonePersonal(d){ return d.contactId==null && d.transactionId==null && personalReminderDone(d); } 2487function dlShowView(view){ 2488 dlView = (view === 'personal') ? 'personal' : 'transactions'; 2489 var on = dlView === 'personal'; 2490 if(ge('dlViewTransactions')) ge('dlViewTransactions').style.display = on ? 'none' : 'block'; 2491 if(ge('dlViewPersonal')) ge('dlViewPersonal').style.display = on ? 'block' : 'none'; 2492 if(ge('dlTabTx')) ge('dlTabTx').className = 'btn ' + (on ? 'btn-g' : 'btn-p'); 2493 if(ge('dlTabPersonal')) ge('dlTabPersonal').className = 'btn ' + (on ? 'btn-p' : 'btn-g'); 2494 if(ge('dlTabTx')) ge('dlTabTx').style.padding = '6px 12px'; 2495 if(ge('dlTabPersonal')) ge('dlTabPersonal').style.padding = '6px 12px'; 2496 if(on) renderPersonalReminders(); else rdl(); 2497} 2498 2499function renderPersonalReminders(){ 2500 var el = ge('prList'); if(!el) return; 2501 el.innerHTML = ''; 2502 var list = personalReminders().filter(function(d){ return !personalReminderDone(d); }).sort(function(a,b){ return String(a.date||'').localeCompare(String(b.date||'')); }); 2503 if(!list.length){ 2504 el.appendChild(mkDiv('font-size:16px;color:var(--text3);padding:20px;text-align:center;
2504','No personal reminders yet. Click "+ Add Reminder".')); 2505 return; 2506 } 2507 list.forEach(function(d){ 2508 var n = d.date ? du(d.date) : null; 2509 var row = document.createElement('div'); 2510 row.style.cssText = 'display:flex;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid var(--border);flex-wrap:wrap;'; 2511 if(n!=null) row.appendChild(mkDot(dc(n))); 2512 var info = mkDiv('flex:1;min-width:150px;'); 2513 info.appendChild(mkDiv('font-size:17px;font-weight:600;color:var(--text);', d.type||'Reminder')); 2514 var m = d.assignedTo ? TM.find(function(x){ return String(x.id)===String(d.assignedTo); }) : null; 2515 var who = m ? fn(m) : 'Me / team'; 2516 info.appendChild(mkDiv('font-size:15px;color:var(--text3);margin-top:2px;', (d.date?fd(d.date):'no date') + (d.time?(' at '+d.time):'') + ' - ' + who)); 2517 row.appendChild(info); 2518 if(n!=null){ 2519 var lbl = n<0 ? Math.abs(n)+'d ago' : n===0 ? 'today' : 'in '+n+'d'; 2520 row.appendChild(mkDiv('font-family:monospace;font-size:15px;color:'+(n<0?'var(--text3)':n<=1?'var(--accent)':'var(--text2)')+';white-space:nowrap;', lbl)); 2521 } 2522 var eb = mkBtn('btn btn-g','Edit','font-size:14px;padding:4px 9px;'); 2523 (function(id){ eb.addEventListener('click', function(){ openPersonalReminder(id); }); })(d.id); 2524 row.appendChild(eb); 2525 var db = mkBtn('btn btn-d','Del','font-size:14px;padding:4px 9px;'); 2526 (function(id){ db.addEventListener('click', function(){ 2527 if(!confirm('Delete this reminder?')) return; 2528 D = D.filter(function(x){ return x.id!==id; }); sv(); 2529 pausePoll(8000); deleteDLfromDB(id); 2530 renderPersonalReminders(); rd(); 2531 }); })(d.id); 2532 row.appendChild(db); 2533 el.appendChild(row); 2534 }); 2535} 2536 2537function openPersonalReminder(id){ 2538 var dl = ge('prTypeList'); 2539 if(dl){ dl.innerHTML=''; rsAllTypes().forEach(function(t){ var o=document.createElement('option'); o.value=t; dl.appendChild(o); }); } 2540 populateAssignDropdowns(); 2541 // populateAssignDropdowns labels the blank option "Unassigned"; for a personal reminder 2542 // that blank means "goes to the whole team", so make that explicit here. 2543 var pa = ge('prAssign'); if(pa && pa.options.length && pa.options[0].value==='') pa.options[0].textContent = 'Me / whole team'; 2544 var d = id ? D.find(function(x){ return String(x.id)===String(id); }) : null; 2545 ge('prName').value = d ? (d.type||'') : ''; 2546 ge('prDate').value = d ? (d.date||'') : tod(); 2547 if(ge('prTime')) ge('prTime').value = d ? (d.time||'') : ''; 2548 if(ge('prAssign')) ge('prAssign').value = (d && d.assignedTo!=null) ? d.assignedTo : ''; 2549 ge('btnSavePR').setAttribute('data-edit-id', id||''); 2550 om('prModal'); 2551} 2552 2553function savePersonalReminder(){ 2554 var name = (ge('prName').value||'').trim(); 2555 if(!name){ alert('Enter a reminder name.'); return; } 2556 var date = ge('prDate').value; 2557 if(!date){ alert('Pick a date.'); return; } 2558 var assign = parseInt(ge('prAssign') && ge('prAssign').value) || null; 2559 var editId = ge('btnSavePR').getAttribute('data-edit-id'); 2560 var time = (ge('prTime') && ge('prTime').value) || null; 2561 if(editId){ 2562 var ex = D.find(function(x){ return String(x.id)===String(editId); }); 2563 if(ex){ ex.type=name; ex.date=date; ex.time=time; ex.assignedTo=assign; ex.contactId=null; ex.transactionId=null; saveDL(ex); } 2564 } else { 2565 var nd = { id:Date.now(), contactId:null, transactionId:null, type:name, date:date, time:time, assignedTo:assign }; 2566 D.push(nd); saveDL(nd); 2567 } 2568 cm('prModal'); renderPersonalReminders(); rd(); 2569} 2570 2571function rdl(){ 2572 var el=ge('dlList'); 2573 el.innerHTML=''; 2574 2575 var filterFrom = ge('dlFilterFrom') ? ge('dlFilterFrom').value : ''; 2576 var filterTo = ge('dlFilterTo') ? ge('dlFilterTo').value : ''; 2577 var filterContact = ge('dlFilterContact') ? ge('dlFilterContact').value : ''; 2578 2579 var REPC_DEADLINES = [ 2580 {key:'earnestDate', label:'Earnest Money Due'}, 2581 {key:'dueDiligDate', label:'Due Diligence Deadline'}, 2582 {key:'financingDate', label:'Financing Deadline'}, 2583 {key:'appraisalDate', label:'Appraisal Deadline'}, 2584 {key:'closingDate', label:'Closing / Settlement Date'} 2585 ]; 2586 2587 function dlTypeLabel(d, tx){ 2588 if(d.type) return d.type; 2589 if(tx){ for(var i=0;i<REPC_DEADLINES.length;
2589i++){ if(tx[REPC_DEADLINES[i].key]===d.date) return REPC_DEADLINES[i].label; } } 2590 return 'Deadline'; 2591 } 2592 function dlDeal(d){ return dlTxFor(d); } 2593 2594 var groups={}, order=[]; 2595 function ensureGroup(key, tx, contactId){ 2596 if(!groups[key]){ 2597 var c = contactId!=null ? gc(contactId) : (tx ? gc(tx.contactId) : null); 2598 var addr = (tx&&tx.address) ? tx.address : (c&&c.property?c.property:''); 2599 groups[key] = { tx:tx, contactId:(contactId!=null?contactId:(tx?tx.contactId:null)), addr:addr, who:(c?fn(c):'Unassigned'), dated:[], missing:[] }; 2600 order.push(key); 2601 } 2602 return groups[key]; 2603 } 2604 2605 D.forEach(function(d){ 2606 if(filterContact && String(d.contactId)!==String(filterContact)) return; 2607 if(filterFrom && d.date < filterFrom) return; 2608 if(filterTo && d.date > filterTo) return; 2609 var tx=dlDeal(d); 2610 if(tx && tx.status==='closed') return; // closed deal: nothing left to track 2611 if(!tx && d.contactId==null) return; // personal reminder -> its own subtab 2612 if(dlDoneStep(d)) return; // overdue + its checklist step complete -> handled 2613 var key = tx ? ('tx_'+tx.id) : ('c_'+d.contactId); 2614 ensureGroup(key, tx, tx?null:d.contactId).dated.push(d); 2615 }); 2616 2617 var _closingAliases=['Closing Date','Closing / Settlement Date']; 2618 function _dlSameSlot(a,b){ return a===b || (_closingAliases.indexOf(a)>=0 && _closingAliases.indexOf(b)>=0); } 2619 TX.filter(function(t){return t.status!=='closed';}
2619).forEach(function(tx){ 2620 if(filterContact && String(tx.contactId)!==String(filterContact)) return; 2621 REPC_DEADLINES.forEach(function(dl){ 2622 // Don't nag about a missing deadline the deal doesn't need: no bank loan (cash/seller) or the 2623 // REPC waived the contingency (8.3(a) financing, 8.2 appraisal, 8.1 due diligence). 2624 if(!tx[dl.key]){ 2625 if(dl.key==='financingDate' && (noBankLoan(tx) || financingWaived(tx))) return; 2626 if(dl.key==='appraisalDate' && (noBankLoan(tx) || appraisalWaived(tx))) return; 2627 if(dl.key==='dueDiligDate' && ddWaived(tx)) return; 2628 } 2629 var rec = D.find(function(d){ 2630 var owns = (d.transactionId!=null && String(d.transactionId)===String(tx.id)) || (d.transactionId==null && String(d.contactId)===String(tx.contactId)); 2631 return owns && _dlSameSlot(d.type, dl.label); 2632 }); 2633 if(tx[dl.key]){ 2634 if(!rec){ 2635 var nd={id:Date.now()+Math.floor(Math.random()*100000), contactId:tx.contactId, transactionId:tx.id, type:dl.label, date:tx[dl.key]}; 2636 D.push(nd); saveDL(nd); 2637 if(!dlDoneStep(nd)) ensureGroup('tx_'+tx.id, tx, null).dated.push(nd); // a completed/waived slot stays hidden 2638 } 2639 } else if(!rec){ 2640 ensureGroup('tx_'+tx.id, tx, null).missing.push({ txId:tx.id, contactId:tx.contactId, label:dl.label, key:dl.key }); 2641 } 2642 }); 2643 }); 2644 2645 order = order.filter(function(k){ return groups[k].dated.length || groups[k].missing.length; }); 2646 order.sort(function(a,b){ 2647 var ga=groups[a], gb=groups[b]; 2648 if(ga.missing.length && !gb.missing.length) return -1; 2649 if(!ga.missing.length && gb.missing.length) return 1; 2650 var ea = ga.dated.length ? Math.min.apply(null, ga.dated.map(function(d){return pld(d.date).getTime();})) : Infinity; 2651 var eb = gb.dated.length ? Math.min.apply(null, gb.dated.map(function(d){return pld(d.date).getTime();})) : Infinity; 2652 return ea-eb; 2653 }); 2654 2655 if(!order.length){ el.innerHTML='<div class="empty">No deadlines match the current filter.</div>'; return; } 2656 2657 var totalDated=0, totalMissing=0; 2658 order.forEach(function(k){ totalDated+=groups[k].dated.length; totalMissing+=groups[k].missing.length; }); 2659 var summary=document.createElement('div'); 2660 summary.style.cssText='font-size:18px;color:var(--text3);margin-bottom:10px;display:flex;gap:12px;flex-wrap:wrap;'; 2661 var s1=document.createElement('span'); s1.textContent=totalDated+' deadline'+(totalDated!==1?'s':''); summary.appendChild(s1); 2662 if(totalMissing){ var s2=document.createElement('span'); s2.style.cssText='color:var(--danger);font-weight:600;'; s2.textContent='\u26A0 '+totalMissing+' missing date'+(totalMissing!==1?'s':'')+' need attention'; summary.appendChild(s2); } 2663 el.appendChild(summary); 2664 2665 order.forEach(function(k){ 2666 var g=groups[k]; 2667 var hdr=document.createElement('div'); 2668 hdr.style.cssText='display:flex;align-items:baseline;gap:10px;margin:22px 0 10px;padding:8px 12px;background:var(--surface2);border-left:4px solid var(--accent);border-radius:6px;'; 2669 var hAddr=document.createElement('div'); hAddr.style.cssText='font-size:18px;font-weight:700;color:var(--accent);letter-spacing:0.3px;'; hAddr.textContent=g.addr||g.who||'Unassigned'; hdr.appendChild(hAddr); 2670 if(g.tx){ hAddr.style.cursor='pointer'; hAddr.title='Open transaction to edit'; hAddr.onmouseenter=function(){hAddr.style.textDecoration='underline';}; hAddr.onmouseleave=function(){hAddr.style.textDecoration='none';}; (function(txId){ hAddr.onclick=function(){ curTx=txId; ge('tcDetEdit').click(); }; })(g.tx.id); } hAddr.textContent=g.addr||g.who||'Unassigned'; hdr.appendChild(hAddr); 2671 if(g.addr && g.who && g.who!=='Unassigned'){ var hWho=document.createElement('div'); hWho.style.cssText='font-size:15px;color:var(--text3);'; hWho.textContent=g.who; hdr.appendChild(hWho); } 2672 el.appendChild(hdr); 2673 2674 g.dated.slice().sort(function(a,b){return pld(a.date)-pld(b.date);}).forEach(function(d){ 2675 var c=gc(d.contactId); var n=du(d.date); 2676 var isOverdue=n<0, isToday=n===0, isUrgent=n<=3&&n>=0; 2677 var row2=document.createElement('div'); 2678 row2.style.cssText='background:var(--surface);border:1px solid '+(isOverdue?'rgba(201,76,76,0.4)':isToday?'var(--accent)':isUrgent?'rgba(201,168,76,0.3)':'var(--border)')+';border-radius:7px;padding:3px 10px;margin:0 0 4px 26px;display:flex;align-items:center;flex-wrap:wrap;gap:4px 8px;';
2679 row2.appendChild(mkDot(dc(n))); 2680 var info2=mkDiv('flex:1;min-width:130px;'); 2681 var typeLbl=document.createElement('div'); 2682 typeLbl.style.cssText='font-size:13px;font-weight:600;color:'+(isOverdue?'var(--danger)':isToday?'var(--accent)':'var(--text)')+';'; 2683 typeLbl.textContent=(isOverdue?'OVERDUE: ':isToday?'TODAY: ':'')+dlTypeLabel(d, g.tx); 2684 info2.appendChild(typeLbl); 2685 row2.appendChild(info2); 2686 var right2=mkDiv('display:flex;align-items:center;gap:6px;margin-left:auto;'); 2687 var dates2=mkDiv('display:flex;align-items:center;gap:8px;white-space:nowrap;'); 2688 var dateTxt=document.createElement('div'); dateTxt.style.cssText='font-family:monospace;font-size:13px;color:'+(isOverdue?'var(--danger)':isToday?'var(--accent)':'var(--text2)')+';font-weight:'+(isOverdue||isToday?'700':'400')+';'; dateTxt.textContent=fd(d.date); 2689 var lbl2=mkDiv('font-size:11px;color:'+(isOverdue?'var(--danger)':isUrgent?'var(--warn)':'var(--text3)')+';', isOverdue?Math.abs(n)+'d overdue':isToday?'TODAY':n+' days'); 2690 dates2.appendChild(dateTxt); dates2.appendChild(lbl2);
2690 right2.appendChild(dates2); 2691 var gcalDlBtn=document.createElement('button'); 2692 gcalDlBtn.style.cssText='background:rgba(76,142,201,0.1);border:1px solid rgba(76,142,201,0.2);border-radius:5px;padding:2px 6px;cursor:pointer;font-size:11px;color:var(--buyer);font-family:DM Sans,sans-serif;white-space:nowrap;'; 2693 gcalDlBtn.textContent='+ GCal'; gcalDlBtn.title='Add to Google Calendar'; 2694 (function(dl2,tx2){ gcalDlBtn.addEventListener('click',function(e){ e.stopPropagation(); var c5=gc(dl2.contactId); syncToGCal({ title:dlTypeLabel(dl2,tx2)+(c5?' - '+fn(c5):''), date:dl2.date,time:'',type:'deadline', clientName:c5?fn(c5):'', notes:'Deadline from Properly CRM' }, dl2.assignedTo); }); })(d,g.tx); 2695 right2.appendChild(gcalDlBtn); 2696 var editBtn2=document.createElement('button'); 2697 editBtn2.style.cssText='background:var(--surface2);border:1px solid var(--border);border-radius:5px;padding:2px 6px;cursor:pointer;font-size:11px;color:var(--text2);font-family:DM Sans,sans-serif;'; 2698 editBtn2.textContent='Edit'; 2699 (function(dl){ editBtn2.addEventListener('click',function(e){ e.stopPropagation(); openEditDL(dl); }); })(d); 2700 right2.appendChild(editBtn2); 2701 var delBtn2=document.createElement('button'); 2702 delBtn2.style.cssText='background:rgba(201,76,76,0.1);border:1px solid rgba(201,76,76,0.2);border-radius:5px;padding:2px 6px;cursor:pointer;font-size:11px;color:var(--danger);font-family:DM Sans,sans-serif;'; 2703 delBtn2.textContent='Del'; 2704 (function(did){ delBtn2.addEventListener('click',function(e){ e.stopPropagation(); if(confirm('Delete this deadline?')){ D=D.filter(function(x){return x.id!==did;}); sv(); pausePoll(8000); deleteDLfromDB(did); rdl(); rd(); }}); })(d.id); 2705 right2.appendChild(delBtn2); 2706 row2.appendChild(right2); 2707 el.appendChild(row2); 2708 }); 2709 2710 g.missing.forEach(function(item){ 2711 var row=document.createElement('div'); 2712 row.style.cssText='background:rgba(201,76,76,0.08);border:1px solid rgba(201,76,76,0.25);border-radius:8px;padding:11px 14px;margin-bottom:8px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;'; 2713 var warn=document.createElement('div'); warn.style.cssText='color:var(--danger);font-size:18px;flex-shrink:0;'; warn.textContent='!'; 2714 var info=document.createElement('div'); info.style.flex='1'; 2715 var lbl=document.createElement('div'); lbl.style.cssText='font-size:18px;font-weight:600;color:var(--danger);'; lbl.textContent=item.label+' needs a date'; 2716 var sub=document.createElement('div'); sub.style.cssText='font-size:16px;color:var(--text3);margin-top:2px;'; sub.textContent='Not found in scanned document'; 2717 info.appendChild(lbl); info.appendChild(sub); 2718 var inp=document.createElement('input'); inp.type='date'; inp.style.cssText='background:var(--surface);border:1px solid rgba(201,76,76,0.4);border-radius:5px;padding:5px 8px;font-size:18px;color:var(--text);font-family:DM Sans,sans-serif;outline:none;'; 2719 var saveBtn2=document.createElement('button'); saveBtn2.className='btn btn-p'; saveBtn2.style.cssText='font-size:18px;padding:5px 12px;'; saveBtn2.textContent='Add Date'; 2720 (function(it, inpEl){ 2721 saveBtn2.addEventListener('click', function(){ 2722 var val=inpEl.value; if(!val){ alert('Please pick a date.'); return; } 2723 var nd={id:Date.now(),contactId:it.contactId,transactionId:it.txId,type:it.label,date:val}; 2724 D.push(nd); saveDL(nd); 2725 var tx2=TX.find(function(t){return String(t.id)===String(it.txId);}); 2726 if(tx2){ tx2[it.key]=val; saveTX(tx2); } 2727 rdl(); rd(); 2728 }); 2729 })(item, inp); 2730 row.appendChild(warn); row.appendChild(info); row.appendChild(inp); row.appendChild(saveBtn2); 2731 el.appendChild(row); 2732 }); 2733 }); 2734} 2735 2736function om(id){ge(id).classList.add('open');} 2737function cm(id){ge(id).classList.remove('open');} 2738function fs(sid){var sel=ge(sid);sel.innerHTML='';C.forEach(function(c){var o=document.createElement('option');o.value=c.id;o.textContent=fn(c)+(ctypes(c).length?' ('+ctypes(c).join(', ')+ ')':'');sel.appendChild(o);});} 2739function fsDeals(selId, contactId, selectedId){ 2740 var sel=ge(selId); if(!sel) return; 2741 sel.innerHTML='';
2742 var o0=document.createElement('option'); o0.value=''; o0.textContent='No deal (whole client)'; sel.appendChild(o0); 2743 var cid=parseInt(contactId); 2744 TX.filter(function(t){return parseInt(t.contactId)===cid;}).forEach(function(t){ 2745 var o=document.createElement('option'); o.value=t.id; 2746 o.textContent=(t.address||'Transaction')+(t.type?' ('+t.type+')':'')+(t.status==='closed'?' - closed':''); 2747 sel.appendChild(o); 2748 }); 2749 if(selectedId!=null && String(selectedId)!=='') sel.value=String(selectedId); 2750} 2751function ofc(id){fs('fuContact');ge('fuContact').value=id;ge('fuDate').value=tod();populateAssignDropdowns();om('fuModal');} 2752// Open the follow-up modal for a specific transaction: prefills the client AND the deal, so you 2753// can schedule a reminder straight from a transaction card or its detail window. 2754function ofcTx(txId){ 2755 var tx = TX.find(function(t){ return String(t.id)===String(txId); }); 2756 if(!tx) return; 2757 if(ge('btnSaveFU')) ge('btnSaveFU').setAttribute('data-edit-id',''); 2758 if(ge('fuLabel')) ge('fuLabel').value=''; 2759 fs('fuContact'); 2760 if(ge('fuContact')) ge('fuContact').value = tx.contactId!=null ? tx.contactId : ''; 2761 fsDeals('fuDeal', tx.contactId, tx.id); // deal dropdown for this client, this deal preselected 2762 if(ge('fuDate')) ge('fuDate').value = tod(); 2763 populateAssignDropdowns(); 2764 if(ge('fuAssign')) ge('fuAssign').value = tx.assignedTo!=null ? tx.assignedTo : ''; 2765 om('fuModal'); 2766} 2767// Fill the deadline Type dropdown from the managed reminder-type list (standard + custom), 2768// so anything added in Reminder Settings is selectable here. 2769function populateDeadlineTypes(selected){ 2770 var sel = ge('dlType'); if(!sel) return; 2771 sel.innerHTML = ''; 2772 rsAllTypes().forEach(function(t){ 2773 var o = document.createElement('option'); o.value = t; o.textContent = t; sel.appendChild(o); 2774 }); 2775 var other = document.createElement('option'); other.value = 'Other'; other.textContent = 'Other'; sel.appendChild(other); 2776 if(selected){ if(!Array.prototype.some.call(sel.options,function(o){return o.value===selected;})){ var e=document.createElement('option'); e.value=selected; e.textContent=selected; sel.appendChild(e); } sel.value = selected; } 2777} 2778// Add a blank first option so a reminder can exist with no client (personal reminders like 2779// "take out the trash"). Those email the assigned team member instead of a client. 2780function dlContactAllowNone(){ 2781 var sel = ge('dlContact'); if(!sel) return; 2782 if(!sel.querySelector('option[value=""]')){ 2783 var o = document.createElement('option'); o.value=''; o.textContent='â None (personal reminder) â'; 2784 sel.insertBefore(o, sel.firstChild); 2785 } 2786} 2787function odc(id){fs('dlContact');dlContactAllowNone();ge('dlContact').value=id;populateDeadlineTypes('');ge('dlDate').value=tod();if(ge('dlTime'))ge('dlTime').value='';populateAssignDropdowns();om('dlModal');} 2788 2789function getFTypeValues(){ 2790 var vals=[]; 2791 document.querySelectorAll('.ftype-chk').forEach(function(cb){ if(cb.checked) vals.push(cb.value); }); 2792 return vals; 2793} 2794function setFTypeValues(types){ 2795 document.querySelectorAll('.ftype-chk').forEach(function(cb){ 2796 cb.checked = types.indexOf(cb.value) >= 0; 2797 }); 2798} 2799function clearFTypeValues(){ 2800 document.querySelectorAll('.ftype-chk').forEach(function(cb){ cb.checked=false; }); 2801} 2802 2803function mvAddRow(containerId, value, label){ 2804 var box = ge(containerId); if(!box) return; 2805 var row = document.createElement('div'); 2806 row.className = 'mv-row'; 2807 row.style.cssText = 'display:flex;gap:6px;margin-bottom:6px;align-items:center;'; 2808 var vi = document.createElement('input'); 2809 vi.className = 'fi mv-val'; vi.style.flex = '1'; 2810 vi.value = value || ''; 2811 vi.setAttribute('placeholder', box.getAttribute('data-ph') || ''); 2812 var li = document.createElement('input'); 2813 li.className = 'fi mv-lbl'; li.style.width = '90px'; 2814 li.value = label || ''; li.setAttribute('placeholder', 'label'); 2815 var rm = document.createElement('button'); 2816 rm.type = 'button'; rm.className = 'btn btn-g'; 2817 rm.style.cssText = 'padding:4px 10px;font-size:18px;'; rm.textContent = 'X'; 2818 rm.addEventListener('click', function(){ box.removeChild(row); }); 2819 row.appendChild(vi); row.appendChild(li); row.appendChild(rm); 2820 box.appendChild(row); 2821} 2822function mvRender(containerId, arr, fallback){ 2823 var box = ge(containerId); if(!box) return; 2824 box.innerHTML = ''; 2825 var list = Array.isArray(arr) ? arr.slice() : []; 2826 if(!list.length && fallback){ list = [{value: fallback, label: ''}]; } 2827 if(!list.length){ mvAddRow(containerId, '', ''); return; } 2828 list.forEach(function(item){ 2829 if(typeof item === 'string'){ mvAddRow(containerId, item, ''); } 2830 else { mvAddRow(containerId, (item && item.value) || '', (item && item.label) || ''); } 2831 }); 2832} 2833function mvCollect(containerId){ 2834 var box = ge(containerId); if(!box) return []; 2835 var out = [], rows = box.querySelectorAll('.mv-row'); 2836 for(var i=0;i<rows.length;i++){ 2837 var v = rows[i].querySelector('.mv-val'), l = rows[i].querySelector('.mv-lbl'); 2838 var val = v ? v.value.trim() : ''; 2839 if(val) out.push({value: val, label: l ? l.value.trim() : ''}); 2840 } 2841 return out; 2842} 2843function openEditContact(id){ 2844 var c = gc(id); 2845 if(!c) return; 2846 ge('addModalTitle') && (ge('addModalTitle').textContent = 'Edit Contact'); 2847 ge('btnSaveContact') && ge('btnSaveContact').setAttribute('data-edit-id', id); 2848 ge('fFirst').value = c.first||''; 2849 ge('fLast').value = c.last||''; 2850 setFTypeValues(ctypes(c)); 2851 mvRender('fPhones', c.phones, c.phone); 2852 mvRender('fEmails', c.emails, c.email); 2853 mvRender('fAddresses', c.addresses, c.property); 2854 ge('fWhatsapp').value = c.whatsapp||''; 2855 ge('fStage').value = c.stage||''; 2856 ge('fPrice').value = c.price||''; 2857 ge('fNotes').value = c.notes||''; 2858 ge('fCloseDate').value = c.closeDate||''; 2859 cd(); // close detail panel 2860 ge('addModal').classList.add('open'); 2861} 2862 2863function svc(){ 2864 var first=ge('fFirst').value.trim(); 2865 var last=ge('fLast').value.trim(); 2866 if(!first&&!last){alert('Please enter at least a first or last name.');return;} 2867 if(!last) last=''; 2868 var editId = ge('btnSaveContact') ? ge('btnSaveContact').getAttribute('data-edit-id') : ''; 2869 if(editId){ 2870 // Edit existing 2871 var c = gc(editId); 2872 if(!c){ 2873 // Contact not found â just close the modal 2874 ge('btnSaveContact') && ge('btnSaveContact').setAttribute('data-edit-id',''); 2875 ge('addModalTitle') && (ge('addModalTitle').textContent = 'Add Contact');
2876 cm('addModal'); 2877 clearFTypeValues(); ge('fStage').value=''; 2878 return; 2879 } 2880 c.first=first; c.last=last; 2881 c.types=getFTypeValues(); c.type=c.types.join('|'); 2882 c.phones=mvCollect('fPhones'); c.emails=mvCollect('fEmails'); c.addresses=mvCollect('fAddresses'); 2883 c.phone=(c.phones[0]&&c.phones[0].value)||''; c.email=(c.emails[0]&&c.emails[0].value)||''; c.property=(c.addresses[0]&&c.addresses[0].value)||''; 2884 c.whatsapp=ge('fWhatsapp').value; 2885 c.stage=ge('fStage').value; 2886 c.price=ge('fPrice').value; c.notes=ge('fNotes').value; 2887 c.closeDate=ge('fCloseDate').value||''; 2888 updateContact(c);logActivity(c.id,'Updated contact'); 2889 ge('btnSaveContact') && ge('btnSaveContact').setAttribute('data-edit-id',''); 2890 ge('addModalTitle') && (ge('addModalTitle').textContent = 'Add Contact'); 2891 cm('addModal'); 2892 ['fFirst','fLast','fWhatsapp','fPrice','fNotes','fCloseDate'].forEach(function(x){ge(x).value='';}); 2893 mvRender('fPhones',[]); mvRender('fEmails',[]); mvRender('fAddresses',[]); 2894 clearFTypeValues(); ge('fStage').value=''; 2895 rd(); if(curPage==='contacts')rc(); if(curPage==='pipeline')rp(); 2896 return; 2897 } else { 2898 var _ftypes=getFTypeValues(); 2899 var _ph=mvCollect('fPhones'), _em=mvCollect('fEmails'), _ad=mvCollect('fAddresses'); 2900 var nc={id:Date.now(),first:first,last:last,types:_ftypes,type:_ftypes.join('|'), 2901 phones:_ph,emails:_em,addresses:_ad,whatsapp:ge('fWhatsapp').value, 2902 phone:(_ph[0]&&_ph[0].value)||'',email:(_em[0]&&_em[0].value)||'', 2903 property:(_ad[0]&&_ad[0].value)||'',stage:ge('fStage').value, 2904 price:ge('fPrice').value,notes:ge('fNotes').value, 2905 closeDate:ge('fCloseDate').value||'', 2906 added:new Date().toISOString()}; 2907 C.push(nc); saveContact(nc); logActivity(nc.id,'Added contact'); 2908 } 2909 cm('addModal'); 2910 ['fFirst','fLast','fWhatsapp','fPrice','fNotes','fCloseDate'].forEach(function(x){ge
2910(x).value='';}); 2911 mvRender('fPhones',[]); mvRender('fEmails',[]); mvRender('fAddresses',[]); 2912 clearFTypeValues(); ge('fStage').value=''; 2913 rd(); if(curPage==='contacts')rc(); if(curPage==='pipeline')rp(); 2914} 2915function svn(){ 2916 var text=ge('nText').value.trim();if(!text)return; 2917 var editId = ge('btnSaveNote') ? ge('btnSaveNote').getAttribute('data-edit-id') : ''; 2918 if(editId){ 2919 var existing=N.find(function(x){return String(x.id)===String(editId);}); 2920 if(existing){ 2921 existing.text=text; 2922 existing.contactId=parseInt(ge('nContact').value); 2923 existing.transactionId=parseInt(ge('nDeal').value)||null; 2924 existing.category=(ge('nCategory')&&ge('nCategory').value)||'General'; 2925 sv(); if(supaReady) dbSave('notes',[existing]); 2926 } 2927 ge('btnSaveNote') && ge('btnSaveNote').setAttribute('data-edit-id',''); 2928 } else { 2929 var nn={id:Date.now(),contactId:parseInt(ge('nContact').value),transactionId:parseInt(ge('nDeal').value)||null,text:text,date:new Date().toISOString(),category:(ge('nCategory')&&ge('nCategory').value)||'General'}; 2930 N.push(nn); saveNote(nn); 2931 } 2932 cm('noteModal'); ge('nText').value=''; rn(); rd(); 2933} 2934function openEditFU(f){ 2935 fs('fuContact'); ge('fuContact').value=f.contactId; 2936 fsDeals('fuDeal', f.contactId, f.transactionId); 2937 ge('fuLabel').value=f.label||''; 2938 ge('fuDate').value=f.date||''; 2939 if(f.pri) ge('fuPri').value=f.pri; 2940 if(ge('fuAssign')) ge('fuAssign').value=(f.assignedTo!=null?f.assignedTo:''); 2941 ge('btnSaveFU') && ge('btnSaveFU').setAttribute('data-edit-id', f.id); 2942 om('fuModal'); 2943} 2944function svfu(){ 2945 var lbl=ge('fuLabel').value.trim(); if(!lbl) return; 2946 var editId = ge('btnSaveFU') ? ge('btnSaveFU').getAttribute('data-edit-id') : ''; 2947 if(editId){ 2948 var existing=F.find(function(x){return String(x.id)===String(editId);}); 2949 if(existing){ 2950 existing.contactId=parseInt(ge('fuContact').value); 2951 existing.transactionId=parseInt(ge('fuDeal').value)||null; 2952 existing.label=lbl; 2953 existing.date=ge('fuDate').value; 2954 existing.pri=ge('fuPri').value; 2955 existing.assignedTo=parseInt(ge('fuAssign')&&ge('fuAssign').value)||null; 2956 sv(); if(supaReady) dbSave('followups',[existing]); 2957 } 2958 ge('btnSaveFU') && ge('btnSaveFU').setAttribute('data-edit-id',''); 2959 } else { 2960 var nf={id:Date.now(),contactId:parseInt(ge('fuContact').value),transactionId:parseInt(ge('fuDeal').value)||null,label:lbl,date:ge('fuDate').value,pri:ge('fuPri').value,done:false,assignedTo:parseInt(ge('fuAssign')&&ge('fuAssign').value)||null}; 2961 F.push(nf); saveFU(nf); 2962 } 2963 cm('fuModal'); ge('fuLabel').value=''; rfu(); rd(); if(curDet)vc(curDet); 2964} 2965function openEditDL(d){ 2966 fs('dlContact'); dlContactAllowNone(); 2967 ge('dlContact').value = d.contactId != null ? d.contactId : ''; 2968 fsDeals('dlDeal', d.contactId, d.transactionId); 2969 populateDeadlineTypes(d.type||''); 2970 ge('dlDate').value=d.date||''; 2971 if(ge('dlTime')) ge('dlTime').value=d.time||''; 2972 populateAssignDropdowns(); 2973 if(ge('dlAssign')) ge('dlAssign').value = d.assignedTo != null ? d.assignedTo : ''; 2974 ge('btnSaveDL') && ge('btnSaveDL').setAttribute('data-edit-id', d.id); 2975 om('dlModal'); 2976} 2977function svdl(){ 2978 var editId = ge('btnSaveDL') ? ge('btnSaveDL').getAttribute('data-edit-id') : ''; 2979 if(editId){ 2980 var existing = D.find(function(x){ return String(x.id)===String(editId); }); 2981 if(existing){ 2982 existing.contactId=parseInt(ge('dlContact').value)||null; // blank = personal reminder 2983 existing.transactionId=parseInt(ge('dlDeal').value)||null; 2984 existing.type=ge('dlType').value; 2985 existing.date=ge('dlDate').value; 2986 existing.time=(ge('dlTime')&&ge('dlTime').value)||null; 2987 existing.assignedTo=parseInt(ge('dlAssign')&&ge('dlAssign').value)||null; 2988 sv(); if(supaReady) dbSave('deadlines',[existing]); 2989 } 2990 ge('btnSaveDL') && ge('btnSaveDL').setAttribute('data-edit-id',''); 2991 } else { 2992 var nd={id:Date.now(),contactId:parseInt(ge('dlContact').value)||null,transactionId:parseInt(ge('dlDeal').value)||null,type:ge('dlType').value,date:ge('dlDate').value,time:(ge('dlTime')&&ge('dlTime').value)||null,assignedTo:parseInt(ge('dlAssign')&&ge('dlAssign').value)||null}; 2993 D.push(nd); saveDL(nd); if(nd.contactId) logActivity(nd.contactId,'Updated deadlines'); 2994 } 2995 cm('dlModal'); rdl(); rd(); if(curDet)vc(curDet); 2996} 2997function adn(){var text=ge('detNoteTA').value.trim();if(!text||!curDet)return;var nn={id:Date.now(),contactId:curDet,text:text,date:new Date().toISOString(),category:'General'};N.push(nn);saveNote(nn);logActivity(curDet,'Added notes');ge('detNoteTA').value='';vc(curDet);rd();} 2998 2999function tsb(){ge('sidebar').classList.toggle('open');ge('sbOv').classList.toggle('open');} 3000function csb(){ge('sidebar').classList.remove('open');ge('sbOv').classList.remove('open');} 3001 3002// -- CSV IMPORT / EXPORT ---------------------------------------------- 3003var csvParsed = []; 3004 3005function exportCSV(){ 3006 var headers = ['First Name','Last Name','Type','Phone','Email','Property','Stage','Price','Notes']; 3007 var rows = [headers.map(csvQ).join(',')];
3008 C.forEach(function(c){ 3009 var row = [ 3010 csvQ(c.first||''), csvQ(c.last||''), csvQ(ctypes(c).join('|')), csvQ(c.phone||''), 3011 csvQ(c.email||''), csvQ(c.property||''), csvQ(c.stage||''), 3012 csvQ(c.price||''), csvQ((c.notes||'').replace(/[\r\n]+/g,' ')) 3013 ]; 3014 rows.push(row.join(',')); 3015 }); 3016 var blob = new Blob([rows.join('\r\n')], {type:'text/csv;charset=utf-8;'}); 3017 var url = URL.createObjectURL(blob); 3018 var a = document.createElement('a'); 3019 a.href = url; 3020 a.download = 'properly-crm-contacts.csv'; 3021 a.click(); 3022 URL.revokeObjectURL(url); 3023} 3024 3025function csvQ(val){ 3026 // Always quote every field - eliminates any comma/newline ambiguity 3027 return '"' + String(val==null?'':val).replace(/"/g,'""').replace(/[\r\n]+/g,' ') + '"'; 3028} 3029 3030function openImport(){ 3031 ge('csvStep1').style.display = 'block'; 3032 ge('csvStep2').style.display = 'none'; 3033 ge('btnConfirmImport').style.display = 'none'; 3034 csvParsed = []; 3035 om('csvModal'); 3036} 3037 3038function parseCSV(text){ 3039 // Normalize line endings 3040 var lines = text.trim().replace(/\r\n/g,'\n').replace(/\r/g,'\n').split('\n'); 3041 if(lines.length < 2) return []; 3042 var headers = csvSplit(lines[0]).map(function(h){ return h.trim().toLowerCase().replace(/"/g,''); }); 3043 var results = []; 3044 var idSeed = Date.now(); 3045 for(var i = 1; i < lines.length; i++){ 3046 var line = lines[i].trim(); 3047 if(!line) continue; 3048 var cols = csvSplit(line); 3049 var obj = {}; 3050 headers.forEach(function(h, idx){ obj[h] = (cols[idx]||'').trim(); }); 3051 3052 // ---- Name: handle Google (Given Name/Family Name), FUB, plain First/Last ---- 3053 var first = obj['given name'] || obj['first name'] || obj['firstname'] || obj['first'] || ''; 3054 var last = obj['family name'] || obj['last name'] || obj['lastname'] || obj['last'] || ''; 3055 // Handle "Name" as full name (split on first space) 3056 if(!first && !last && obj['name']){ 3057 var parts = obj['name'].trim().split(/\s+/); 3058 first = parts[0]||''; last = parts.slice(1).join(' ')||''; 3059 } 3060 3061 // ---- Email: "Email 1/2/3", Google "e-mail 1 - value", FUB "email address" ---- 3062 var emails = csvCollectMV(obj, headers, /e-?mail/, /relationship|type|label/); 3063 var email = (emails[0] && emails[0].value) || ''; 3064 3065 // ---- Phone: "Phone 1/2/3", Google "phone 1 - value", FUB "home phone" ---- 3066 var phones = csvCollectMV(obj, headers, /phone|mobile|cell/, /relationship|type|label/); 3067 var phone = (phones[0] && phones[0].value) || ''; 3068 3069 // Skip junk rows 3070 if(!first && !last && !email && !phone) continue; 3071 if(first && (/^\d{4}-\d{2}-\d{2}/.test(first) || first.indexOf('http')===0)) continue; 3072 3073 // ---- Address: combine FUB address fields or Google "address 1 - formatted" ---- 3074 var address = obj['address 1 - formatted'] || obj['address 1 formatted'] || ''; 3075 if(!address){ 3076 var street = obj['street'] || obj['address'] || obj['street address'] || ''; 3077 var city = obj['city'] || ''; 3078 var state = obj['state'] || ''; 3079 var zip = obj['zip'] || obj['postal code'] || obj['zip code'] || ''; 3080 var parts2 = [street, city, state, zip].filter(Boolean); 3081 address = parts2.join(', '); 3082 } 3083 3084 // ---- Notes: combine FUB notes + tags ---- 3085 var notes = obj['notes'] || obj['note'] || obj['comments'] || ''; 3086 var tags = obj['tags'] || obj['tag'] || ''; 3087 if(tags) notes = notes ? notes + ' | Tags: ' + tags : 'Tags: ' + tags; 3088 3089 // ---- Type from FUB "type" or our own format ---- 3090 var rawType = obj['type'] || obj['contact type'] || ''; 3091 var types = rawType ? rawType.split('|').map(function(t){ return normalizeType(t.trim()); }).filter(Boolean) : []; 3092 3093 // ---- Stage: only accept an explicit, recognized stage. ---- 3094 // Unrecognized values never become a pipeline stage; preserve them in notes. 3095 var stageRaw = obj['stage'] || obj['status'] || obj['lead stage'] || ''; 3096 var stage = normalizeStage(stageRaw); 3097 if(stageRaw.trim() && !stage){ 3098 notes = notes ? notes + ' | Source stage: ' + stageRaw.trim() : 'Source stage: ' + stageRaw.trim(); 3099 } 3100 3101 var addresses = address ? [{value: address, label: ''}] : []; 3102 var propertyVal = obj['property'] || obj['interest'] || address; 3103 3104 var c = { 3105 id: idSeed + i, 3106 first: first, 3107 last: last, 3108 types: types, 3109 type: types.join('|'), 3110 phone: phone, 3111 email: email, 3112 phones: phones, 3113 emails: emails, 3114 addresses: addresses, 3115 property: propertyVal, 3116 stage: stage, 3117 price: obj['price'] || obj['budget'] || '', 3118 notes: notes, 3119 added: new Date().toISOString() 3120 }; 3121 results.push(c); 3122 } 3123 return results; 3124} 3125 3126function csvCollectMV(obj, headers, rx, skipRx){ 3127 var out = [], seen = {}; 3128 for(var hi = 0; hi < headers.length; hi++){ 3129 var h = headers[hi]; 3130 if(!rx.test(h)) continue; 3131 if(skipRx && skipRx.test(h)) continue; 3132 var raw = (obj[h] || '').trim(); 3133 if(!raw) continue; 3134 var label = h.replace(/e-?mail|phone|mobile|cell|address|value|[0-9]+|[-:]/g, '').trim(); 3135 var parts = raw.split(/\s*[;,|]\s*/);
3136 for(var pi = 0; pi < parts.length; pi++){ 3137 var v = parts[pi].trim(); 3138 if(!v) continue; 3139 var k = v.toLowerCase(); 3140 if(seen[k]) continue; 3141 seen[k] = 1; 3142 out.push({value: v, label: label}); 3143 } 3144 } 3145 return out; 3146} 3147 3148function csvSplit(line){ 3149 var result = [], cur = '', inQ = false; 3150 for(var i = 0; i < line.length; i++){ 3151 var ch = line[i]; 3152 if(ch === '"'){ if(inQ && line[i+1] === '"'){ cur += '"'; i++; } else inQ = !inQ; } 3153 else if(ch === ',' && !inQ){ result.push(cur); cur = ''; } 3154 else cur += ch; 3155 } 3156 result.push(cur); 3157 return result; 3158} 3159 3160function normalizeType(t){ 3161 t = (t||'').toLowerCase().trim(); 3162 if(!t) return ''; 3163 if(t === 'buyer') return 'buyer'; 3164 if(t === 'seller') return 'seller'; 3165 if(t === 'agent' || t === 'broker' || t === 'agent/broker') return 'agent'; 3166 if(t === 'lender' || t === 'loan officer' || t === 'lo') return 'lender'; 3167 if(t === 'title' || t === 'title company') return 'title'; 3168 if(t === 'vendor') return 'vendor'; 3169 if(t === 'other') return 'other'; 3170 if(t === 'lead' || t === 'prospect') return 'prospect'; 3171 return 'other'; 3172} 3173 3174function normalizeStage(s){ 3175 s = (s||'').trim(); 3176 if(!s) return ''; 3177 var stages = ['New Lead','Contacted','Showing','Under Contract','Closed']; 3178 for(var i = 0; i < stages.length; i++){ 3179 if(stages[i].toLowerCase() === s.toLowerCase()) return stages[i]; 3180 } 3181 return ''; 3182} 3183 3184function findDupe(nc){ 3185 // match by email (if both have one) OR by first+last name (case-insensitive) 3186 var em = (nc.email||'').toLowerCase().trim(); 3187 var nm = (nc.first+' '+nc.last).toLowerCase().trim(); 3188 for(var i=0;i<C.length;i++){ 3189 var c=C[i]; 3190 if(em && c.email && c.email.toLowerCase().trim()===em) return c; 3191 if(nm && (c.first+' '+c.last).toLowerCase().trim()===nm) return c; 3192 } 3193 return null; 3194} 3195 3196// per-row import decision: 'skip' | 'import' 3197var csvDecisions = {}; 3198 3199function showCSVPreview(contacts){ 3200 csvParsed = contacts; 3201 csvDecisions = {}; 3202 var preview = ge('csvPreview'); 3203 preview.innerHTML = ''; 3204 3205 var newCount=0, dupeCount=0;
3206 contacts.forEach(function(c){ if(findDupe(c)) dupeCount++; else newCount++; }); 3207 3208 var summary = document.createElement('div'); 3209 summary.style.cssText = 'font-size:15px;color:var(--text2);margin-bottom:12px;display:flex;gap:16px;flex-wrap:wrap;'; 3210 summary.innerHTML = '<span><b style="color:var(--lead);">'+newCount+' new</b> ready to import</span>' 3211 + (dupeCount ? '<span><b style="color:var(--warn);">'+dupeCount+' duplicates</b> found - choose action per row</span>' : ''); 3212 preview.appendChild(summary); 3213 3214 var wrap = document.createElement('div'); 3215 wrap.style.cssText = 'max-height:320px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;'; 3216 var table = document.createElement('table'); 3217 table.style.cssText = 'width:100%;border-collapse:collapse;font-size:14px;'; 3218 var thead = document.createElement('thead'); 3219 var hrow = document.createElement('tr'); 3220 hrow.style.background = 'var(--surface2)'; 3221 ['Name','Type','Phone','Status','Action'].forEach(function(h){ 3222 var th = document.createElement('th'); 3223 th.style.cssText = 'padding:8px 10px;text-align:left;color:var(--text3);font-weight:600;font-size:13px;'; 3224 th.textContent = h; 3225 hrow.appendChild(th); 3226 }); 3227 thead.appendChild(hrow); table.appendChild(thead); 3228 var tbody = document.createElement('tbody'); 3229
3230 contacts.forEach(function(c, i){ 3231 var dupe = findDupe(c); 3232 var tr = document.createElement('tr'); 3233 tr.style.cssText = 'border-bottom:1px solid var(--border);'+(dupe?'background:rgba(201,168,76,0.05);':''); 3234 3235 var td1=document.createElement('td'); td1.style.cssText='padding:8px 10px;color:var(--text);'; 3236 td1.textContent=(c.first+' '+c.last).trim()||(c.email||''); 3237 if(dupe){ 3238 var dupNote=document.createElement('div'); 3239 dupNote.style.cssText='font-size:12px;color:var(--warn);margin-top:2px;'; 3240 dupNote.textContent='Matches: '+(dupe.first+' '+dupe.last).trim()+(dupe.email?' ('+dupe.email+')':''); 3241 td1.appendChild(dupNote); 3242 } 3243 3244 var td2=document.createElement('td'); td2.style.padding='8px 10px'; 3245 ctypes(c).forEach(function(_t){ var tg=document.createElement('span'); tg.className='tag tag-'+_t; tg.style.cssText='font-size:12px;margin-right:3px;'; tg.textContent=_t; td2.appendChild(tg); }); 3246 3247 var td3=document.createElement('td'); td3.style.cssText='padding:8px 10px;color:var(--text2);font-size:13px;'; 3248 td3.textContent=c.phone||'-'; 3249 3250 var td4=document.createElement('td'); td4.style.cssText='padding:8px 10px;color:var(--text2);font-size:13px;'; 3251 td4.textContent=c.stage||'-'; 3252 3253 var td5=document.createElement('td'); td5.style.cssText='padding:8px 10px;'; 3254 if(dupe){ 3255 var sel=document.createElement('select'); sel.className='fsel'; 3256 sel.style.cssText='font-size:13px;padding:3px 6px;'; 3257 [['skip','Skip (keep existing)'],['import','Import as new']].forEach(function(opt){ 3258 var o=document.createElement('option'); o.value=opt[0]; o.textContent=opt[1]; sel.appendChild(o); 3259 }); 3260 sel.value='skip'; 3261 csvDecisions[i]='skip'; 3262 (function(idx){ sel.addEventListener('change',function(){ csvDecisions[idx]=this.value; }); })(i); 3263 td5.appendChild(sel); 3264 } else { 3265 csvDecisions[i]='import'; 3266 var badge=document.createElement('span'); 3267 badge.style.cssText='font-size:12px;color:var(--lead);font-weight:600;'; 3268 badge.textContent='Import'; 3269 td5.appendChild(badge); 3270 } 3271 3272 tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tr.appendChild(td4); tr.appendChild(td5); 3273 tbody.appendChild(tr); 3274 }); 3275 3276 table.appendChild(tbody); wrap.appendChild(table); preview.appendChild(wrap); 3277 ge('csvStep1').style.display = 'none'; 3278 ge('csvStep2').style.display = 'block'; 3279 ge('btnConfirmImport').style.display = 'inline-flex'; 3280} 3281 3282function confirmImport(){ 3283 var toSave=[], skipped=0; 3284 var usedIds = {}; 3285 C.forEach(function(ec){ usedIds[ec.id] = 1; }); 3286 var idSeed = Date.now(); 3287 csvParsed.forEach(function(nc, i){ 3288 if(csvDecisions[i]==='skip'){ skipped++; return; } 3289 var newId = idSeed + i; 3290 while(usedIds[newId]){ newId++; } 3291 usedIds[newId] = 1; 3292 nc.id = newId; 3293 nc.added = nc.added || new Date().toISOString(); 3294 C.push(nc); toSave.push(nc); 3295 }); 3296 sv(); 3297 cm('csvModal'); 3298 var msg = 'Imported '+toSave.length+' contact'+(toSave.length!==1?'s':'')+'.'+(skipped?' Skipped '+skipped+'.':''); 3299 if(!supaReady || !toSave.length){ 3300 rd(); if(curPage==='contacts') rc(); 3301 alert(msg); return; 3302 } 3303 // Pause poll, chunk saves to avoid Supabase request size limits 3304 pausePoll(60000); 3305 showSyncStatus('loading'); 3306 var chunkSize = 20; 3307 var chunks = []; 3308 for(var ci=0; ci<toSave.length; ci+=chunkSize){ 3309 chunks.push(toSave.slice(ci, ci+chunkSize)); 3310 } 3311 var chunkIndex = 0; 3312 function saveNextChunk(){ 3313 if(chunkIndex >= chunks.length){ 3314 // All chunks done â reload from DB to confirm 3315 setTimeout(function(){ 3316 loadFromDB().then(function(){ 3317 rd(); if(curPage==='contacts') rc(); 3318 alert(msg); 3319 }); 3320 }, 1000); 3321 return; 3322 } 3323 getAuthHeaders({'Prefer':'resolution=merge-duplicates'}).then(function(headers){ 3324 fetch(SUPA_URL+'/rest/v1/contacts', { 3325 method:'POST', headers:headers, 3326 body:JSON.stringify(chunks[chunkIndex].map(function(r){ return stripForDB('contacts',r); })) 3327 }).then(function(r){ 3328 if(!r.ok){ 3329 r.text().then(function(t){ 3330 console.error('Chunk '+(chunkIndex+1)+' save error (status '+r.status+'):', t); 3331 // Show first error to user so we can diagnose 3332 if(chunkIndex===0) alert('Supabase save error: ' + t.substring(0,200)); 3333 }); 3334 } 3335 chunkIndex++; 3336 saveNextChunk(); 3337 }).catch(function(e){ 3338 console.error('Chunk save fetch error:', e.message); 3339 chunkIndex++; 3340 saveNextChunk(); 3341 }); 3342 }); 3343 } 3344 saveNextChunk(); 3345} 3346 3347// -- TRANSACTION COORDINATOR ------------------------------------------ 3348var TX = []; 3349var curTx = null; 3350var tcFilter = 'all'; 3351 3352// Checklist templates based on your actual Palacios Baker forms 3353var TC_TEMPLATES = { 3354 buyer: [ 3355 {phase:'Phase 1 - Intake & Pre-Approval', steps:[ 3356 {label:'Pre-approval & funds verified',key:'b1_preapproval',desc:'Connection established, pre-approval letter secured, and down payment funds verified.'}, 3357 {label:'Buyer agreement signed & uploaded',key:'b1_agreement',desc:'Fully executed agency agreement uploaded into Dotloop.'}, 3358 {label:'MLS search & alerts active',key:'b1_mls',desc:'Search parameters configured and automated notifications active.'} 3359 ]}, 3360 {phase:'Phase 2 - Home Search', steps:[ 3361 {label:'Target property CMA & disclosures reviewed',key:'b2_cma',desc:'Comprehensive market evaluation, history, and property disclosures reviewed prior to offer preparation.'} 3362 ]}, 3363 {phase:'Phase 3 - Under Contract', steps:[ 3364 {label:'Offer submitted & docs uploaded in Dotloop',key:'b3_offer',owner:'tc',due:{anchor:'contractDate',offset:1},desc:'Executed purchase contract submitted and files organized in compliance repository.'}, 3365 {label:'Earnest money delivered',key:'b3_earnest',hasDate:true,dateField:'earnestDate',desc:'Earnest money confirmation received and logged.'}, 3366 {label:'Due diligence deadline tracked',key:'b3_duedilig',hasDate:true,dateField:'dueDiligDate',desc:'Contractual tracking window active.'}, 3367 {label:'Financing deadline tracked',key:'b3_financing',hasDate:true,dateField:'financingDate',desc:'Contractual tracking window active.'}, 3368 {label:'Appraisal',key:'b3_appr',hasDate:true,dateField:'appraisalDate',completion:true,desc:'Appraisal received and reviewed. Check when complete (or when waived / N/A).'}, 3369 {label:'Home inspection completed & repairs negotiated',key:'b3_inspection',owner:'agent',due:{anchor:'contractDate',offset:7},desc:'Physical inspection performed, report evaluated, and repair addenda executed.'}, 3370 {label:'Title commitment reviewed & confirmed',key:'b3_title',owner:'title',due:{anchor:'contractDate',offset:10},desc:'Preliminary title insurance commitment reviewed for encumbrances.'}, 3371 {label:'Final walkthrough scheduled & completed',key:'b3_walkthrough',owner:'agent',due:{anchor:'closingDate',offset:-1},desc:'Final property condition verification completed with buyer.'}, 3372 {label:'Closing time, utilities, & funds confirmed',key:'b3_closingprep',owner:'tc',due:{anchor:'closingDate',offset:-3},desc:'Signing window locked, utility transfer reminders sent, and final wire figures verified.'}, 3373 {label:'Recording & keys confirmed',key:'b3_recording',owner:'tc',due:{anchor:'closingDate',offset:1},desc:'Public recordation confirmed and possession transfers completed.'} 3374 ]}, 3375 {phase:'Phase 4 - Post-Close Nurture', steps:[ 3376 {label:'Settlement statement reviewed & uploaded',key:'b4_settlement',owner:'tc',due:{anchor:'closingDate',offset:1},desc:'Final ALTA / settlement statement uploaded to long-term storage.'}, 3377 {label:'Closing gift delivered',key:'b4_gift',desc:'Closing appreciation item prepared and delivered.'}, 3378 {label:'Review & testimonial requested',key:'b4_review',owner:'agent',due:{anchor:'closingDate',offset:7},desc:'Digital feedback and testimonial requests sent to client.'}, 3379 {label:'CRM updated',key:'b4_crm',desc:'Contact records updated with past client tags, property anniversary date, and birthdays.'}, 3380 {label:'Long-term nurture campaign started',key:'b4_nurture',desc:'Long-term client relationship and marketing sequence initiated.'}, 3381 {label:'Milestone follow-ups',key:'b4_milestones',desc:'Follow-up schedule systematically initiated across standard milestones (7-day, 30-day, 90-day, 1-year).'} 3382 ]} 3383 ], 3384 seller: [ 3385 {phase:'Phase 1 - Listing Intake & Property Docs', steps:[ 3386 {label:'Property documentation gathered',key:'s1_docs',desc:'Consolidated gathering of HOA docs, payoff balances, tax records, utility averages, and solar/lease details.'}, 3387 {label:'Listing agreement & seller disclosures completed',key:'s1_agreement',desc:'Exclusive listing agreement and statutory disclosures fully completed and signed.'}, 3388 {label:'Preliminary title opened & lien search requested',key:'s1_title',desc:'Escrow opened with title company and early lien verification requested.'} 3389 ]}, 3390 {phase:'Phase 2 - Listing Go-Live', steps:[ 3391 {label:'CMA prepared & pricing finalized',key:'s2_cma',desc:'Market data updated and final base list price established.'}, 3392 {label:'Media completed',key:'s2_media',desc:'High-definition interior/exterior photos, drone captures, floor plans, and social media walkthrough videos finalized.'}, 3393 {label:'Sign, lockbox, & showing instructions active',key:'s2_signage',desc:'Physical lockbox placed, yard sign installed, and showing instructions configured in system.'}, 3394 {label:'MLS active & verified',key:'s2_mls',desc:'Listing made active on the MLS and data integrity checked.'}, 3395 {label:'Marketing blast launched',key:'s2_marketing',desc:'Omnichannel marketing campaign deployed across social networks, local databases, and printed flyers.'}, 3396 {label:'Open house scheduled & executed',key:'s2_openhouse',desc:'Public or broker open house events coordinated and executed.'} 3397 ]}, 3398 {phase:'Phase 3 - Under Contract', steps:[ 3399 {label:'Offer accepted/countered & uploaded',key:'s3_offer',owner:'tc',due:{anchor:'contractDate',offset:1},desc:'Ratified purchase contract uploaded to internal CRM compliance track.'}, 3400 {label:'Earnest money received',key:'s3_earnest',hasDate:true,dateField:'earnestDate',desc:'Verification of earnest money deposit holding received.'}, 3401 {label:'Due diligence deadline tracked',key:'s3_duedilig',hasDate:true,dateField:'dueDiligDate',desc:'Contractual tracking window active.'}, 3402 {label:'Financing deadline tracked',key:'s3_financing',hasDate:true,dateField:'financingDate',desc:'Contractual tracking window active.'}, 3403 {label:'Appraisal',key:'s3_appr',hasDate:true,dateField:'appraisalDate',completion:true,desc:"Buyer's appraisal completed and valuation contingency satisfied. Check when complete (or when waived / N/A)."}, 3404 {label:'Buyer inspection & repair negotiations completed',key:'s3_inspection',owner:'agent',due:{anchor:'contractDate',offset:7},desc:"Buyer's home inspection finalized and resolution addendum successfully executed."}, 3405 {label:'Title commitment reviewed',key:'s3_title',owner:'title',due:{anchor:'contractDate',offset:10},desc:'Seller title obligations verified clear for transfer.'}, 3406 {label:'Signing date set & utilities reminded',key:'s3_signing',owner:'tc',due:{anchor:'closingDate',offset:-3},desc:'Settlement appointment locked and client reminded to terminate active utility accounts.'}, 3407 {label:'Final walkthrough & funds verification completed',key:'s3_walkthrough',owner:'agent',due:{anchor:'closingDate',offset:-1},desc:'Buyer final walkthrough completed and funds availability confirmed.'}, 3408 {label:'Recording confirmed & proceeds wired',key:'s3_recording',owner:'tc',due:{anchor:'closingDate',offset:1},desc:"Title recording confirmed by county and net proceeds wired to seller's account."}, 3409 {label:'Keys handed over / possession complete',key:'s3_keys',owner:'agent',due:{anchor:'closingDate',offset:0},desc:'Property keys relinquished and possession transferred per
3409contract.'} 3410 ]}, 3411 {phase:'Phase 4 - Post-Close Nurture', steps:[ 3412 {label:'Settlement statement reviewed & uploaded',key:'s4_settlement',owner:'tc',due:{anchor:'closingDate',offset:1},desc:'Final closing statement archived internally.'}, 3413 {label:'Closing gift delivered',key:'s4_gift',desc:'Appreciation gesture delivered to client.'}, 3414 {label:'Review & testimonial requested',key:'s4_review',owner:'agent',due:{anchor:'closingDate',offset:7},desc:'Requests for online performance reviews and testimonials transmitted.'}, 3415 {label:'CRM updated',key:'s4_crm',desc:'Client dataset updated with past seller tags, transaction data notes, and dates.'}, 3416 {label:'Long-term nurture campaign started',key:'s4_nurture',desc:'Post-sale value-add communication sequence activated.'}, 3417 {label:'Milestone follow-ups',key:'s4_milestones',desc:'Follow-up schedule systematically initiated across standard milestones.'} 3418 ]} 3419 ] 3420}; 3421 3422// ââ Templated workflow: turn TC_TEMPLATES steps that carry a `due` rule into dated deadline 3423// records so they inherit reminders, priorities, and the portal. Idempotent (deduped by 3424// stepKey per deal). Steps without a `due` rule stay as plain checklist items. ââââââââââââ 3425var TASK_OWNER_LABEL = { tc:'Coordinator', agent:'Agent', lender:'Lender', title:'Title/Escrow', client:'Client' }; 3426function _resolveDueDate(tx, due){ 3427 if(!due || !due.anchor) return ''; 3428 var base = due.anchor==='open' ? (tx.contractDate || tx.added || '') : (tx[due.anchor] || ''); 3429 if(!base) return ''; 3430 var dt = new Date(base); if(isNaN(dt.getTime())) return ''; 3431 dt.setDate(dt.getDate() + (due.offset||0)); 3432 return dt.toISOString().slice(0,10); 3433} 3434// One-time migration: the two appraisal items (auto-tracked + "appraisal & loan approved") were 3435// collapsed into a single "Appraisal" completion step (b3_appr / s3_appr). Carry the old real 3436// "completed" mark (b3_apprloan / s3_appraisaldone) onto the new key, and drop the legacy keys so 3437// nothing lingers. The old auto-tracked b3_appraisal/s3_appraisal was "tracking", not "done", so 3438// it is intentionally NOT treated as completion. 3439function migrateAppraisalSteps(){ 3440 (TX||[]).forEach(function(t){ 3441 if(!t || !t.steps) return; 3442 var s = t.steps, dirty = false; 3443 if(('b3_apprloan' in s) || ('b3_appraisal' in s)){ 3444 if(s.b3_apprloan && !s.b3_appr){ s.b3_appr = true; } 3445 delete s.b3_apprloan; delete s.b3_appraisal; dirty = true; 3446 } 3447 if(('s3_appraisaldone' in s) || ('s3_appraisal' in s)){ 3448 if(s.s3_appraisaldone && !s.s3_appr){ s.s3_appr = true; } 3449 delete s.s3_appraisaldone; delete s.s3_appraisal; dirty = true; 3450 } 3451 if(dirty){ sv(); if(supaReady) dbSave('transactions', [t]); } 3452 }); 3453} 3454// Build/refresh the generated tasks for one deal (or all active deals when tx omitted). 3455function materializeWorkflowTasks(txArg){ 3456 var list = txArg ? [txArg] : TX.filter(function(t){ return t.status!=='closed'; }); 3457 var changed = false; 3458 // Retire generated deadlines whose step no longer exists in the template (e.g. the removed 3459 // "Appraisal completed & loan approved" task). Orphaned stepKey deadlines would otherwise linger. 3460 (function(){ 3461 var valid = {}; 3462 Object.keys(TC_TEMPLATES).forEach(function(ty){ 3463 (TC_TEMPLATES[ty]||[]).forEach(function(ph){ (ph.steps||[]).forEach(function(s){ valid[s.key]=true; }); }); 3464 }); 3465 var orphans = D.filter(function(d){ return d.stepKey && !valid[d.stepKey]; }); 3466 if(orphans.length){ 3467 var oids = {}; orphans.forEach(function(d){ oids[d.id]=true; }); 3468 D = D.filter(function(d){ return !oids[d.id]; }); 3469 if(supaReady) orphans.forEach(function(d){ dbDeleteBy('deadlines','id',d.id); }); 3470 changed = true; 3471 } 3472 })(); 3473 list.forEach(function(tx){ 3474 if(!tx || tx.status==='closed') return; 3475 var tmpl = TC_TEMPLATES[tx.type] || TC_TEMPLATES['buyer']; 3476 tmpl.forEach(function(phase){ 3477 (phase.steps||[]).forEach(function(step){ 3478 if(!step.due) return; // only date-drivable steps 3479 if(step.owner==='lender' && (noBankLoan(tx) || financingWaived(tx))){ // no bank loan/financing ->
3479 no lender task 3480 var exL = D.find(function(d){ return d.stepKey===step.key && String(d.transactionId)===String(tx.id); }); 3481 if(exL){ D = D.filter(function(d){ return d!==exL; }); if(supaReady) dbDeleteBy('deadlines','id',exL.id); changed=true; } 3482 return; 3483 } 3484 var already = D.find(function(d){ return d.stepKey===step.key && String(d.transactionId)===String(tx.id); }); 3485 var stepDone = !!(tx.steps && tx.steps[step.key]); 3486 if(stepDone){ // step checked off -> retire its task 3487 if(already){ D = D.filter(function(d){ return d!==already; }); if(supaReady) dbDeleteBy('deadlines','id',already.id); changed=true; } 3488 return; 3489 } 3490 var date = _resolveDueDate(tx, step.due); 3491 if(!date) return; // anchor date not set yet 3492 if(already){ 3493 if(already.date!==date){ already.date=date; saveDL(already); changed=true; } // anchor moved -> reschedule 3494 return; 3495 } 3496 var nd = { id:Date.now()+Math.floor(Math.random()*100000), contactId:tx.contactId, transactionId:tx.id, 3497 type:step.label, date:date, stepKey:step.key, owner:step.owner||'tc' }; 3498 D.push(nd); saveDL(nd); changed = true; 3499 }); 3500 }); 3501 }); 3502 return changed; 3503} 3504function saveTX(tx){ recomputeCommission(tx); sv(); if(supaReady && tx) dbSave('transactions', [tx]); try{ materializeWorkflowTasks(tx); }catch(e){} } 3505// A transaction's address is its own field, captured up front. Never borrow the contact's address â 3506// the contact may hold a prior/other address that has nothing to do with this deal. 3507function txAddr(tx){ return (tx && tx.address) ? tx.address : ''; } 3508function deleteTX(id){ 3509 var _delTx = TX.find(function(t){ return String(t.id)===String(id); }); 3510 var _delCid = _delTx ? _delTx.contactId : null; 3511 var txDocs = DOCS.filter(function(dc){ return String(dc.transaction_id)===String(id); }); 3512 var dlList = D.filter(function(d){ return String(d.transactionId)===String(id); }); 3513 var fuList = F.filter(function(f){ return String(f.transactionId)===String(id); }); 3514 var ntList = N.filter(function(n){ return String(n.transactionId)===String(id); }); 3515 var cascades = []; 3516 if(txDocs.length) cascades.push({id:'docs', label: txDocs.length+' document'+(txDocs.length===1?'':'s')}); 3517 if(dlList.length) cascades.push({id:'deadlines', label: dlList.length+' deadline'+(dlList.length===1?'':'s')}); 3518 if(fuList.length) cascades.push({id:'followups', label: fuList.length+' follow-up'+(fuList.length===1?'':'s')}); 3519 if(ntList.length) cascades.push({id:'notes', label: ntList.length+' note'+(ntList.length===1?'':'s')}); 3520 3521 openDeleteConfirm({ 3522 title: 'Delete transaction', 3523 warnLine: 'Deleting this transaction is permanent and cannot be undone. The contact is kept (and removed from the pipeline board if this was their only open deal).', 3524 cascades: cascades, 3525 onConfirm: async function(del){ 3526 // Transaction row + its change history: always removed. 3527 TX = TX.filter(function(t){ return String(t.id)!==String(id); }); 3528 CH = CH.filter(function(h){ return String(h.transactionId)!==String(id); }); 3529 pausePoll(20000); 3530 dbDeleteBy('transactions', 'id', id); 3531 dbDeleteBy('tx_changes', 'transactionId', id); 3532 // Each cascade: delete if checked, otherwise unlink (transactionId=null) so it survives. 3533 if(del.deadlines){ D = D.filter(function(d){ return String(d.transactionId)!==String(id); }); dbDeleteBy('deadlines','transactionId',id); } 3534 else dlList.forEach(function(d){ d.transactionId=null; saveDL(d); }); 3535 if(del.followups){ F = F.filter(function(f){ return String(f.transactionId)!==String(id); }); dbDeleteBy('followups','transactionId',id); } 3536 else fuList.forEach(function(f){ f.transactionId=null; saveFU(f); }); 3537 if(del.notes){ N = N.filter(function(n){ return String(n.transactionId)!==String(id); }); dbDeleteBy('notes','transactionId',id); } 3538 else ntList.forEach(function(n){ n.transactionId=null; saveNote(n); }); 3539 if(del.docs && txDocs.length){ try{ await bulkDeleteDocuments(txDocs); }catch(e){} } 3540 else txDocs.forEach(function(dc){ dc.transaction_id=null; if(supaReady) dbSave('documents',[dc]); }); 3541 // A cancelled deal shouldn't linger on the pipeline. If the contact has no other OPEN deal, 3542 // clear their pipeline stage so the board reflects reality. The contact is always kept. 3543 if(_delCid != null){ 3544 var _stillOpen = TX.some(function(t){ return String(t.contactId)===String(_delCid) && t.status!=='closed'; }); 3545 var _oc = gc(_delCid); 3546 if(!_stillOpen && _oc && (_oc.stage==='Under Contract' || _oc.stage==='Closed')){ ss(_delCid, ''); } 3547 } 3548 sv(); 3549 var ov=ge('tcDetOv'); if(ov) ov.classList.remove('open'); 3550 renderTC(); rd(); if(curPage==='deadlines') dlShowView(dlView); 3551 } 3552 }); 3553} 3554function initTC(){ renderTC(); updateNbTC(); } 3555function updateNbTC(){ 3556 var active = TX.filter(function(t){ return t.status !== 'closed'; }).length; 3557 var el = ge('nbTC'); if(el) el.textContent = active; 3558} 3559 3560function tcProgress(tx){ 3561 var template = TC_TEMPLATES[tx.type] || TC_TEMPLATES['buyer']; 3562 var total = 0, done = 0; 3563 template.forEach(function(phase){ 3564 phase.steps.forEach(function(step){ 3565 total++; 3566 if(tx.steps && tx.steps[step.key]) done++; 3567 }); 3568 }); 3569 return total > 0 ? Math.round((done/total)*100) : 0; 3570} 3571 3572function tcNextSteps(tx){ 3573 var template = TC_TEMPLATES[tx.type] || TC_TEMPLATES['buyer']; 3574 var next = []; 3575 for(var i=0;i<template.length;i++){ 3576 var phase = template[i]; 3577 for(var j=0;j<phase.steps.length;j++){ 3578 var step = phase.steps[j]; 3579 if(!tx.steps || !tx.steps[step.key]){ 3580 next.push({phase:phase.phase, label:step.label}); 3581 if(next.length >= 3) return next; 3582 } 3583 } 3584 } 3585 return next; 3586} 3587 3588// ===================== CONTRACT RISK REVIEW ===================== 3589// Deterministic checks computed live from the transaction's own data - no AI, no guessing, so 3590// these never miss the obvious and clear themselves the moment you fix the underlying issue. 3591function _riskFieldLabel(f){ 3592 return ({closingDate:'Closing date', financingDate:'Financing deadline', dueDiligDate:'Due diligence deadline', 3593 appraisalDate:'Appraisal deadline', earnestDate:'Earnest money deadline', price:'Purchase price'})[f] || f; 3594} 3595// A past deadline date is only a real risk if its work isn't done yet. Each checklist step 3596// records the dateField it covers (e.g. earnestDate), so if that step is checked, the past date 3597// has been handled and must not flag. This stops "earnest money deadline is in the past" from 3598// nagging every day once the earnest money is received and the step is marked complete. 3599function _dateHandled(tx, field){ 3600 var tmpl = (typeof TC_TEMPLATES !== 'undefined') ? (TC_TEMPLATES[tx.type] || TC_TEMPLATES['buyer']) : null; 3601 if(!tmpl || !tx.steps) return false;
3602 for(var i=0;i<tmpl.length;i++){ 3603 var steps = tmpl[i].steps || []; 3604 for(var j=0;j<steps.length;j++){ 3605 if(steps[j].dateField === field && tx.steps[steps[j].key]) return true; 3606 } 3607 } 3608 return false; 3609} 3610function computeTxRisks(tx){ 3611 var risks = []; 3612 if(!tx || tx.status === 'closed') return risks; // closed deals aren't live risk 3613 // These checks are residential-purchase specific (closing, earnest, financing, appraisal). A 3614 // commercial lease has none of those, so skip them - lease-specific risk checks are a later add. 3615 if(tx.category === 'commercial_lease') return risks; 3616 function add(sev, msg){ risks.push({sev: sev, msg: msg, key: 'risk:'+msg}); } 3617 var today = tod(); 3618 var cd = tx.contractDate, close = tx.closingDate; 3619 var deadlines = [ 3620 {k:'earnestDate', label:'Earnest money deadline'}, 3621 {k:'dueDiligDate', label:'Due diligence deadline'}, 3622 {k:'financingDate', label:'Financing deadline'}, 3623 {k:'appraisalDate', label:'Appraisal deadline'} 3624 ]; 3625 // Financing + appraisal contingencies don't apply when there's no bank loan (cash / seller) OR 3626 // the REPC itself waived them (8.3(a) no financing required, 8.2 appraisal "is not"). Drop those 3627 // date checks so they don't false-alarm. 3628 var _dropFin = noBankLoan(tx) || financingWaived(tx); 3629 var _dropAppr = noBankLoan(tx) || appraisalWaived(tx); 3630 deadlines = deadlines.filter(function(d){ 3631 if(d.k==='financingDate' && _dropFin) return false; 3632 if(d.k==='appraisalDate' && _dropAppr) return false; 3633 return true; 3634 }); 3635 3636 // Missing essentials 3637 if(!close) add('red', 'No closing / settlement date set.'); 3638 if(!_dropFin && !tx.financingDate) add('caution', 'No financing deadline set.'); 3639 if(!ddWaived(tx) && !tx.dueDiligDate) add('caution', 'No due diligence deadline set.'); 3640 if(!tx.earnestDate) add('caution', 'No earnest money deadline set.'); 3641 3642 // Date-order sanity 3643 if(cd && close && close < cd) add('red', 'Closing date is BEFORE the contract date.'); 3644 deadlines.forEach(function(d){ 3645 var v = tx[d.k]; if(!v) return; 3646 if(close && v > close) add('red', d.label + ' falls AFTER closing (' + fd(v) + ' vs close ' + fd(close) + ').'); 3647 if(v < today && !_dateHandled(tx, d.k)) add('caution', d.label + ' is in the past (' + fd(v) + ').'); 3648 }); 3649 3650 // Tight windows (from contract date) 3651 if(!_dropFin && cd && tx.financingDate){ 3652 var fdays = Math.round((pld(tx.financingDate) - pld(cd)) / 864e5); 3653 if(fdays >= 0 && fdays < 14) add('caution', 'Financing deadline is only ' + fdays + ' day' + (fdays===1?'':'s') + ' after contract - tight.'); 3654 } 3655 if(cd && tx.dueDiligDate){ 3656 var ddays = Math.round((pld(tx.dueDiligDate) - pld(cd)) / 864e5); 3657 if(ddays >= 0 && ddays < 7) add('caution', 'Due diligence window is only ' + ddays + ' day' + (ddays===1?'':'s') + ' - short.'); 3658 } 3659 3660 // Addendum red flags, straight from the change log we already keep. 3661 CH.filter(function(h){ return String(h.transactionId) === String(tx.id); }).forEach(function(h){ 3662 var dateFields = ['closingDate','financingDate','dueDiligDate','appraisalDate','earnestDate']; 3663 var by = (h.addendumNo != null) ? (' by addendum #' + h.addendumNo) : ''; 3664 if(dateFields.indexOf(h.field) >= 0 && h.oldValue && h.newValue && String(h.newValue) < String(h.oldValue)){ 3665 add('caution', _riskFieldLabel(h.field) + ' was moved EARLIER' + by + ' (' + h.oldValue + ' â ' + h.newValue + ').'); 3666 } 3667 if(h.field === 'price' && h.oldValue && h.newValue){ 3668 var op = parsePrice(h.oldValue), np = parsePrice(h.newValue); 3669 if(op && np && Math.abs(np - op) / op >= 0.03) add('caution', 'Purchase price changed' + by + ' (' + h.oldValue + ' â ' + h.newValue + ').'); 3670 } 3671 }); 3672 3673 // Seller financing: the right rules for an owner-carry deal. Instead of chasing a bank loan, 3674 // make sure the note is documented and its terms are complete. 3675 if(tx.financingType === 'seller'){ 3676 var sf = txSF(tx); 3677 var hasNoteDoc = DOCS.some(function(d){ 3678 var t = ((d.doc_type||'') + ' ' + ((d.extracted&&d.extracted.docType)||'') + ' ' + (d.file_name||'')).toLowerCase(); 3679 return /seller financ|promissory|trust deed|deed of trust|owner financ|carry|aitd|land contract|contract for deed/.test(t); 3680 }); 3681 if(!hasNoteDoc) add('caution', 'Seller-financed: no promissory note / seller-financing addendum on file.'); 3682 if(!sf.rate) add('caution', 'Seller financing: interest rate not recorded.'); 3683 if(!sf.payment) add('caution', 'Seller financing: monthly payment not recorded.'); 3684 if(!sf.termMonths && !sf.balloonDate) add('caution', 'Seller financing: loan term / balloon date not recorded.'); 3685 if(sf.balloonDate){ 3686 if(close && sf.balloonDate < close){ 3687 add('red', 'Seller financing: balloon date is BEFORE closing (' + fd(sf.balloonDate) + ').'); 3688 } else { 3689 var _bd = Math.round((pld(sf.balloonDate) - pld(tod())) / 864e5); 3690 if(_bd >= 0 && _bd <= 120) add('caution', 'Seller financing: balloon payment due in ' + _bd + ' day' + (_bd===1?'':'s') + ' (' + fd(sf.balloonDate) + ').'); 3691 } 3692 } 3693 if(String(sf.dueOnSale||'').trim().toLowerCase().indexOf('no') === 0){ 3694 add('caution', 'Seller financing: no due-on-sale clause noted - confirm this is intended.'); 3695 } 3696 } 3697 3698 return risks; 3699} 3700// ---- Contract intelligence: cross-document discrepancies + AI advisory ---- 3701// Deterministic wherever it matters. Reads each document's stored extraction (documents.extracted) 3702// and (1) checks stable fields agree across documents, (2) checks the transaction reflects the 3703// newest authoritative document, (3) surfaces the AI's own redFlags from reading the contracts. 3704function computeTxIntel(tx){ 3705 var out = { discrepancies: [], aiFlags: [] }; 3706 if(!tx) return out; 3707 var docs = DOCS.filter(function(d){ return String(d.transaction_id)===String(tx.id) && d.extracted; }); 3708 if(!docs.length) return out; 3709 3710 function docLabel(d){ 3711 var e=d.extracted||{}, t=e.docType||d.doc_type||'Document'; 3712 if(/addendum/i.test(t) && e.addendumNumber) return 'Addendum #'+e.addendumNumber; 3713 return t; 3714 } 3715 3716 // (3) AI advisory: aggregate each document's redFlags, dedupe, keep the source doc. 3717 var seen={}; 3718 docs.forEach(function(d){
3719 (d.extracted.redFlags||[]).forEach(function(f){ 3720 if(!f) return; 3721 var key=String(f).trim().toLowerCase(); 3722 if(!key || seen[key]) return; seen[key]=true; 3723 out.aiFlags.push({ msg:String(f).trim(), src:docLabel(d), key:'ai:'+key }); 3724 }); 3725 }); 3726 3727 // (1) Stable fields shouldn't differ between documents on the same deal. 3728 function norm(s){ return String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,' ').trim(); } 3729 [['buyerName','Buyer name'],['sellerName','Seller name'],['address','Property address']].forEach(function(pair){ 3730 var field=pair[0], label=pair[1], vals=[]; 3731 docs.forEach(function(d){ var v=(d.extracted[field]||'').trim(); if(v) vals.push({v:v, n:norm(v), src:docLabel(d)}); }); 3732 for(var i=1;i<vals.length;i++){ 3733 if(vals[i].n !== vals[0].n){ 3734 out.discrepancies.push({ sev:'caution', key:'party:'+field+':'+vals[0].n+'|'+vals[i].n, 3735 msg: label+' differs across documents: "'+vals[0].v+'" ('+vals[0].src+') vs "'+vals[i].v+'" ('+vals[i].src+'). Verify the right file is attached.' }); 3736 break; 3737 } 3738 } 3739 }); 3740 3741 // (2) Transaction vs authority document. Authority = highest-numbered addendum stating the 3742 // field, else a base document (REPC). Catches an addendum change that never got applied. 3743 function addNum(d){ var e=d.extracted||{}, t=e.docType||d.doc_type||''; return /addendum/i.test(t) ? (parseInt(e.addendumNumber,10)||0) : -1; } 3744 function authorityFor(field){ 3745 var best=null, bestRank=-2; 3746 docs.forEach(function(d){ 3747 var v=(d.extracted[field]||'').toString().trim(); if(!v) return; 3748 var rank=addNum(d); 3749 if(rank>bestRank){ bestRank=rank; best={v:v, src:docLabel(d)}; } 3750 }); 3751 return best; 3752 } 3753 [['closingDate','closingDate','Closing date'], 3754 ['earnestMoneyDeadline','earnestDate','Earnest money deadline'], 3755 ['dueDiligenceDeadline','dueDiligDate','Due diligence deadline'], 3756 ['financingDeadline','financingDate','Financing deadline'], 3757 ['appraisalDeadline','appraisalDate','Appraisal deadline']].forEach(function(m){ 3758 var auth=authorityFor(m[0]); if(!auth) return; 3759 var txv=(tx[m[1]]||'').toString().trim(); 3760 if(txv && auth.v && txv!==auth.v){ 3761 out.discrepancies.push({ sev:'red', key:'val:'+m[1]+':'+auth.v+'>'+txv, 3762 msg: m[2]+': '+auth.src+' shows '+fd(auth.v)+', but the transaction has '+fd(txv)+'. Confirm the transaction reflects the latest document.' }); 3763 } 3764 }); 3765 var pAuth=authorityFor('purchasePrice'); 3766 if(pAuth && tx.price){ 3767 var pa=parsePrice(pAuth.v), pb=parsePrice(tx.price); 3768 if(pa && pb && Math.abs(pa-pb)/pb >= 0.005){ 3769 out.discrepancies.push({ sev:'red', key:'val:price:'+pAuth.v+'>'+tx.price, 3770 msg: 'Purchase price: '+pAuth.src+' shows '+pAuth.v+', but the transaction has '+tx.price+'. Confirm the latest document is applied.' }); 3771 } 3772 } 3773 [['listingCommissionPct','listCommissionPct','Listing commission'], 3774 ['buyerCommissionPct','buyerCommissionPct','Buyer commission']].forEach(function(m){ 3775 var auth=authorityFor(m[0]); if(!auth) return; 3776 var txv=(tx[m[1]]!=null?String(tx[m[1]]):'').trim(); 3777 if(txv && auth.v && parseFloat(auth.v)!==parseFloat(txv)){ 3778 out.discrepancies.push({ sev:'caution', key:'val:'+m[1]+':'+auth.v+'>'+txv, 3779 msg: m[2]+': '+auth.src+' shows '+auth.v+'%, but the transaction has '+txv+'%.' }); 3780 } 3781 }); 3782 3783 return out; 3784} 3785// Reviewed/dismissed risk flags: keyed by a stable, wording-independent id so a dismissal sticks 3786// (and matches the same flag in the daily email). 3787function isRiskDismissed(tx, key){ var d = tx && tx.dismissedRisks; return !!(d && key && d.indexOf(key) >= 0); } 3788function dismissRisk(txId, key){ 3789 var tx = TX.find(function(t){ return t.id === txId; }); if(!tx || !key) return; 3790 tx.dismissedRisks = tx.dismissedRisks || []; 3791 if(tx.dismissedRisks.indexOf(key) < 0) tx.dismissedRisks.push(key); 3792 saveTX(tx); openTCDetail(txId); rd(); 3793} 3794function restoreRisk(txId, key){ 3795 var tx = TX.find(function(t){ return t.id === txId; }); if(!tx || !tx.dismissedRisks) return; 3796 tx.dismissedRisks = tx.dismissedRisks.filter(function(x){ return x !== key; }); 3797 saveTX(tx); openTCDetail(txId); rd(); 3798} 3799function renderRiskReview(tx){ 3800 var risks = computeTxRisks(tx); 3801 var intel = computeTxIntel(tx); 3802 var aiDisp = function(f){ return f.msg + (f.src ? (' â ' + f.src) : ''); }; 3803 3804 // Unified flag list: {msg, key, color, sev}. AI flags share the accent color. 3805 var all = [] 3806 .concat(risks.map(function(r){ return {msg:r.msg, key:r.key, sev:r.sev, color:(r.sev==='red'?'var(--danger)':'var(--warn)'), grp:'Computed checks', gc:'var(--text3)'}; })) 3807 .concat(intel.discrepancies.map(function(r){ return {msg:r.msg, key:r.key, sev:r.sev, color:(r.sev==='red'?'var(--danger)':'var(--warn)'), grp:'Document discrepancies', gc:'var(--seller)'}; })) 3808 .concat(intel.aiFlags.map(function(f){ return {msg:aiDisp(f), key:f.key, sev:'ai', color:'var(--accent)', grp:'AI advisory â from the contract scan (verify)', gc:'var(--accent)'}; })); 3809 3810 var active = all.filter(function(x){ return !isRiskDismissed(tx, x.key); }); 3811 var reviewed = all.filter(function(x){ return isRiskDismissed(tx, x.key); }); 3812 if(!active.length && !reviewed.length) return null; 3813 3814 var sec = document.createElement('div'); 3815 sec.style.cssText = 'padding:12px 20px;border-bottom:1px solid var(--border);background:rgba(201,76,76,0.06);'; 3816 function subhdr(text, color){ return mkDiv('font-size:13px;text-transform:uppercase;letter-spacing:1px;color:'+color+';font-weight:700;margin:10px 0 4px;', text); } 3817 function flagRow(f){ 3818 var row = document.createElement('div'); 3819 row.style.cssText='display:flex;gap:8px;align-items:flex-start;padding:5px 0;font-size:15px;color:var(--text);'; 3820 var dot=mkDiv('flex:0 0 auto;width:9px;height:9px;border-radius:50%;margin-top:6px;background:'+f.color+';',''); 3821 var txt=mkDiv('flex:1;', f.msg); 3822 var btn=document.createElement('button'); 3823 btn.textContent='â Reviewed'; btn.title='Mark reviewed â stop flagging this'; 3824 btn.style.cssText='flex:0 0 auto;background:var(--surface2);border:1px solid var(--border);border-radius:5px;padding:2px 9px;font-size:12px;color:var(--text2);cursor:pointer;font-family:DM Sans,sans-serif;'; 3825 (function(k){ btn.addEventListener('click', function(){ dismissRisk(tx.id, k); }); })(f.key); 3826 row.appendChild(dot); row.appendChild(txt); row.appendChild(btn); 3827 return row; 3828 } 3829 3830 var reds = active.filter(function(x){ return x.sev==='red'; }).length; 3831 sec.appendChild(mkDiv('font-size:14px;text-transform:uppercase;letter-spacing:1.2px;color:var(--danger);font-weight:700;margin-bottom:4px;', 3832 'â Risk Review â ' + active.length + ' flag' + (active.length===1?'':'s') + (reds?(' ('+reds+' serious)'):''))); 3833 3834 // Active flags, grouped in order. 3835 ['Computed checks','Document discrepancies','AI advisory â from the contract scan (verify)'].forEach(function(grp){ 3836 var rows = active.filter(function(x){ return x.grp===grp; }); 3837 if(!rows.length) return; 3838 sec.appendChild(subhdr(grp, rows[0].gc)); 3839 rows.forEach(function(f){ sec.appendChild(flagRow(f)); }); 3840 }); 3841 if(!active.length) sec.appendChild(mkDiv('font-size:14px;color:var(--lead);margin-top:4px;', 'â All risk items reviewed.')); 3842 3843 // Reviewed (dismissed) â collapsible, with restore. 3844 if(reviewed.length){ 3845 var wrap=document.createElement('div'); wrap.style.cssText='margin-top:12px;border-top:1px dashed var(--border);padding-top:8px;'; 3846 var toggle=mkDiv('font-size:13px;color:var(--text3);cursor:pointer;user-select:none;', 'Reviewed ('+reviewed.length+') â show'); 3847 var list=document.createElement('div'); list.style.display='none'; 3848 toggle.addEventListener('click', function(){ var open=(list.style.display==='none'); list.style.display=open?'':'none'; toggle.textContent='Reviewed ('+reviewed.length+') â '+(open?'hide':'show'); }); 3849 reviewed.forEach(function(f){ 3850 var row=document.createElement('div'); 3851 row.style.cssText='display:flex;gap:8px;align-items:flex-start;padding:4px 0;font-size:14px;color:var(--text3);'; 3852 row.appendChild(mkDiv('flex:1;text-decoration:line-through;', f.msg)); 3853 var rb=document.createElement('button'); 3854 rb.textContent='Restore'; 3855 rb.style.cssText='flex:0 0 auto;background:none;border:1px solid var(--border);border-radius:5px;padding:2px 9px;font-size:12px;color:var(--text2);cursor:pointer;font-family:DM Sans,sans-serif;'; 3856 (function(k){ rb.addEventListener('click', function(){ restoreRisk(tx.id, k); }); })(f.key); 3857 row.appendChild(rb); list.appendChild(row); 3858 }); 3859 wrap.appendChild(toggle); wrap.appendChild(list); sec.appendChild(wrap); 3860 } 3861 return sec; 3862} 3863 3864// ===================== TODAY'S PRIORITIES (proactive) ===================== 3865// One ranked, explainable list across every deal - deterministic scoring, no AI deciding your 3866// day. Higher score = more urgent. Feeds the Briefing tab and the morning email. 3867function computePriorities(){ 3868 var items = []; 3869 var activeTx = TX.filter(function(t){ return t.status!=='closed'; }); 3870 var txById = {}; activeTx.forEach(function(t){ txById[String(t.id)] = t; }); 3871 function who(tx, c){ return (tx && tx.address) ? tx.address : (c ? fn(c) : 'Transaction'); } 3872 3873 // 1. Serious (red) risk flags on active deals. Dismissed ("reviewed") flags are excluded. 3874 activeTx.forEach(function(tx){ 3875 computeTxRisks(tx).filter(function(r){ return r.sev==='red' && !isRiskDismissed(tx, r.key); }
3875).forEach(function(r){ 3876 items.push({ score:1000, sev:'red', tx:tx, contactId:tx.contactId, who:who(tx, gc(tx.contactId)), reason:r.msg }); 3877 }); 3878 }); 3879 // 1b. Serious document discrepancies (e.g. an addendum change never applied to the deal). 3880 // Deterministic; keeps chasing you until the transaction is reconciled (or reviewed). 3881 activeTx.forEach(function(tx){ 3882 computeTxIntel(tx).discrepancies.filter(function(r){ return r.sev==='red' && !isRiskDismissed(tx, r.key); }).forEach(function(r){ 3883 items.push({ score:980, sev:'red', tx:tx, contactId:tx.contactId, who:who(tx, gc(tx.contactId)), reason:r.msg }); 3884 }); 3885 }); 3886 // 2. Deadlines you must act on, due within the next 7 days. 3887 D.forEach(function(d){ 3888 if(dlIsClosed(d)) return; 3889 if(dlDoneStep(d)) return; // its checklist step is complete - not a live priority 3890 if(d.contactId==null && d.transactionId==null && personalReminderDone(d)) return; 3891 if(!d.date) return; var n = du(d.date); if(n > 2) return; // overdue or within ~2 days only 3892 var c = d.contactId!=null ? gc(d.contactId) : null; 3893 var tx = d.transactionId!=null ? txById[String(d.transactionId)] : null; 3894 var dwho = c ? fn(c) : (tx ? (tx.address || (gc(tx.contactId) ? fn(gc(tx.contactId)) : '')) : ''); 3895 items.push({ score:760-n*20, sev:(n<=1?'red':'caution'), tx:tx, contactId:(d.contactId!=null?d.contactId:(tx?tx.contactId:null)), 3896 who:(dwho || d.type), reason:d.type+' '+(n<0?(Math.abs(n)+'d overdue'):n===0?'today':n===1?'tomorrow':'in '+n+' days')+' ('+fd(d.date)+')' }); 3897 }); 3898 // 3. Overdue follow-ups. 3899 F.filter(function(f){ return !f.done; }).forEach(function(f){ 3900 if(!f.date) return; var n = du(f.date); if(n>0) return; 3901 var c = gc(f.contactId); var tx = f.transactionId!=null ? txById[String(f.transactionId)] : null; 3902 var fwho = c ? fn(c) : (tx ? (tx.address || (gc(tx.contactId) ? fn(gc(tx.contactId)) : '')) : ''); 3903 items.push({ score:820+Math.min(Math.abs(n),30)*4, sev:(n<0?'red':'caution'), tx:tx, contactId:(f.contactId!=null?f.contactId:(tx?tx.contactId:null)), 3904 who:(fwho || 'Follow-up'), reason:(n===0?'Follow-up due today':'Follow-up '+Math.abs(n)+'d overdue')+': '+f.label }); 3905 }); 3906 // 4. Deals gone quiet: under contract, deadline within 14 days, nothing logged in 7+ days. 3907 activeTx.filter(function(t){ return t.contractDate; }
3907).forEach(function(tx){ 3908 var next=null; 3909 ['financingDate','dueDiligDate','appraisalDate','closingDate','earnestDate'].forEach(function(k){ 3910 if(tx[k]){ var n=du(tx[k]); if(n>=0 && (next==null||n<next)) next=n; } 3911 }); 3912 if(next==null || next>14) return; 3913 var last=null; 3914 N.forEach(function(nn){ if(String(nn.contactId)===String(tx.contactId)){ var t=new Date(nn.date).getTime(); if(!isNaN(t) && (last==null||t>last)) last=t; } }); 3915 var since = last!=null ? Math.round((Date.now()-last)/864e5) : null; 3916 if(since!=null && since<7) return; 3917 items.push({ score:520+(14-next)*8, sev:'caution', tx:tx, contactId:tx.contactId, who:who(tx, gc(tx.contactId)), 3918 reason:'Deadline in '+next+'d, nothing logged '+(since==null?'yet':'in '+since+'d')+'.' }); 3919 }); 3920 3921 items.sort(function(a,b){ return b.score - a.score; }); 3922 return items; 3923} 3924// AI "Rundown": one warm sentence over the Priorities list. Grounded in the computed list so it 3925// can't invent anything, and cached by a signature of that list so it isn't re-called every poll. 3926var _rundown = { sig:'', text:'' }; 3927async function generateRundown(items){ 3928 var el = ge('bRundown'); if(!el) return; 3929 if(!items.length){ el.style.display='none'; el.textContent=''; return; } 3930 var sig = items.slice(0,15).map(function(i){ return i.sev+'|'+i.who+'|'+i.reason; }).join('~'); 3931 if(_rundown.sig===sig && _rundown.text){ el.textContent=_rundown.text; el.style.display='block'; return; } 3932 el.style.display='block'; el.textContent='â¦'; 3933 var lines = items.slice(0,15).map(function(i){ return (i.sev==='red'?'[urgent] ':'')+i.who+': '+i.reason; }).join('\n'); 3934 var prompt = 'You are a concise assistant for a busy real estate agent. Below is their ranked to-do list for today. ' 3935 + 'Write ONE warm, natural sentence (about 25-30 words max) that summarizes the most important 1-3 items so they know their day at a glance. ' 3936 + 'Be specific with names and timeframes. Do NOT invent anything that is not in the list.\n\nLIST:\n' + lines; 3937 try{ 3938 var resp = await fetch('/api/claude', { method:'POST', headers: await apiHeaders(), 3939 body: JSON.stringify({ max_tokens:120, messages:[{ role:'user', content: prompt }] }) }); 3940 var data = await resp.json(); 3941 var txt = (data.content && data.content[0] && data.content[0].text) ? data.content[0].text.trim() : ''; 3942 if(txt){ _rundown = { sig:sig, text:txt }; el.textContent = txt; el.style.display='block'; } 3943 else { el.style.display='none'; } 3944 }catch(e){ el.style.display='none'; } // fail quietly - the ranked list below still stands 3945} 3946 3947function renderBriefingPriorities(){ 3948 var el = ge('bPriorities'); if(!el) return; 3949 el.innerHTML = ''; 3950 var items = computePriorities(); 3951 generateRundown(items); 3952 if(!items.length){ el.appendChild(mkDiv('font-size:16px;color:var(--text3);padding:8px 0;','Nothing needs your attention right now. Nice.')); return; } 3953 items.slice(0,15).forEach(function(it){ 3954 var row = document.createElement('div'); 3955 row.style.cssText = 'display:flex;gap:10px;align-items:flex-start;padding:9px 0;border-bottom:1px solid var(--border);flex-wrap:wrap;'; 3956 row.appendChild(mkDiv('flex:0 0 auto;width:9px;height:9px;border-radius:50%;margin-top:6px;background:'+(it.sev==='red'?'var(--danger)':'var(--warn)')+';','')); 3957 var info = mkDiv('flex:1;min-width:170px;'); 3958 info.appendChild(mkDiv('font-size:17px;font-weight:600;color:var(--text);', it.who)); 3959 info.appendChild(mkDiv('font-size:15px;color:var(--text3);margin-top:1px;', it.reason)); 3960 row.appendChild(info); 3961 var acts = mkDiv('display:flex;gap:6px;flex-wrap:wrap;'); 3962 var ob = mkBtnEl('btn btn-g','Open','font-size:13px;padding:4px 9px;'); 3963 (function(x){ ob.addEventListener('click', function(){ if(x.tx){ sp('tc'); openTCDetail(x.tx.id); } else if(x.contactId!=null){ vc(x.contactId); } }); })(it); 3964 acts.appendChild(ob); 3965 var rb2 = mkBtnEl('btn btn-g','+ Reminder','font-size:13px;padding:4px 9px;'); 3966 (function(x){ rb2.addEventListener('click', function(){ if(x.tx){ ofcTx(x.tx.id); } else if(x.contactId!=null){ ofc(x.contactId); } }); })(it); 3967 acts.appendChild(rb2); 3968 row.appendChild(acts); 3969 el.appendChild(row); 3970 }); 3971} 3972 3973function renderTC(){ 3974 var grid = ge('tcGrid'); 3975 grid.innerHTML = ''; 3976 var list = tcFilter === 'all' ? TX.filter(function(t){ return t.status !== 'closed'; }) 3977 : tcFilter === 'closed' ? TX.filter(function(t){ return t.status === 'closed'; }) 3978 : TX.filter(function(t){ return t.type === tcFilter && t.status !== 'closed'; }); 3979 3980 if(!list.length){ 3981 grid.innerHTML = '<div class="empty" style="grid-column:1/-1">No transactions yet. Click <b>+ New Transaction</b> to get started.</div>'; 3982 return; 3983 } 3984 3985 list.forEach(function(tx){ 3986 var prog = tcProgress(tx); 3987 var next = tcNextSteps(tx); 3988 var c = gc(tx.contactId); 3989 var daysToClose = tx.closingDate ? du(tx.closingDate) : null; 3990 3991 var card = document.createElement('div'); 3992 card.className = 'tc-card'; 3993 (function(txId){ card.addEventListener('click', function(){ openTCDetail(txId); }); })(tx.id); 3994 3995 var hdr = document.createElement('div'); 3996 hdr.className = 'tc-card-header'; 3997 var tl = document.createElement('div'); 3998 tl.style.flex = '1'; 3999 var title = document.createElement('div'); 4000 title.className = 'tc-card-title'; 4001 title.textContent = txAddr(tx) || 'No address'; 4002 var sub = document.createElement('div'); 4003 sub.style.cssText = 'font-size:18px;color:var(--text3);margin-top:2px;'; 4004 sub.textContent = c ? fn(c) : 'Unknown client'; 4005 tl.appendChild(title); tl.appendChild(sub); 4006 var _risks = computeTxRisks(tx); 4007 if(_risks.length){ 4008 var _reds = _risks.filter(function(r){ return r.sev==='red'; }).length; 4009 var rflag = mkDiv('font-size:13px;font-weight:700;margin-top:3px;color:'+(_reds?'var(--danger)':'var(--warn)')+';',
4010 'â ' + _risks.length + ' risk flag' + (_risks.length===1?'':'s')); 4011 tl.appendChild(rflag); 4012 } 4013 var badge = document.createElement('span'); 4014 badge.className = 'tc-type-badge tc-' + tx.type; 4015 badge.textContent = tx.type; 4016 hdr.appendChild(tl); hdr.appendChild(badge); 4017 card.appendChild(hdr); 4018 4019 var body = document.createElement('div'); 4020 body.className = 'tc-card-body'; 4021 4022 // Progress bar 4023 var progRow = document.createElement('div'); 4024 progRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;'; 4025 var progLbl = document.createElement('div'); 4026 progLbl.style.cssText = 'font-size:18px;color:var(--text3);'; 4027 progLbl.textContent = prog + '% complete'; 4028 var progPct = document.createElement('div'); 4029 progPct.style.cssText = 'font-family:monospace;font-size:18px;color:var(--accent);'; 4030 progPct.textContent = prog + '%'; 4031 progRow.appendChild(progLbl); progRow.appendChild(progPct); 4032 var bar = document.createElement('div'); 4033 bar.className = 'tc-progress-bar'; 4034 var fill = document.createElement('div'); 4035 fill.className = 'tc-progress-fill'; 4036 fill.style.width = prog + '%'; 4037 fill.style.background = prog >= 100 ? 'var(--lead)' : prog >= 50 ? 'var(--accent)' : 'var(--buyer)'; 4038 bar.appendChild(fill); 4039 body.appendChild(progRow); body.appendChild(bar); 4040 4041 // Key dates 4042 if(tx.closingDate || tx.price){ 4043 var dates = document.createElement('div'); 4044 dates.style.cssText = 'display:flex;gap:12px;margin:10px 0 8px;flex-wrap:wrap;'; 4045 if(tx.price){ var p=document.createElement('div'); p.style.cssText='font-size:18px;'; p.innerHTML='<span style="color:var(--text3);">Price:</span> <b style="color:var(--accent);">'+tx.price+'</b>'; dates.appendChild(p); } 4046 if(tx.listCommissionAmt!=null){ var lc=document.createElement('div'); lc.style.cssText='font-size:18px;'; lc.innerHTML='<span style="color:var(--text3);">List comm:</span> <b>'+fmtUSD(tx.listCommissionAmt)+(tx.listCommissionPct!=null?' ('+tx.listCommissionPct+'%)':'')+'</b>'; dates.appendChild(lc); } 4047 if(tx.buyerCommissionAmt!=null){ var bc=document.createElement('div'); bc.style.cssText='font-size:18px;'; bc.innerHTML='<span style="color:var(--text3);">Buyer comm:</span> <b>'+fmtUSD(tx.buyerCommissionAmt)+(tx.buyerCommissionPct!=null?' ('+tx.buyerCommissionPct+'%)':'')+'</b>'; dates.appendChild(bc); } 4048 if(tx.soldTerms){ var st=document.createElement('div'); st.style.cssText='font-size:18px;'; st.innerHTML='<span style="color:var(--text3);">Terms:</span> <b>'+tx.soldTerms+'</b>'; dates.appendChild(st); } 4049 if(tx.concessions){ var cn=document.createElement('div'); cn.style.cssText='font-size:18px;'; cn.innerHTML='<span style="color:var(--text3);">Concessions:</span> <b>'+tx.concessions+'</b>'; dates.appendChild(cn); } 4050 if(tx.financingType && tx.financingType!=='conventional'){ var ftEl=document.createElement('div'); ftEl.style.cssText='font-size:18px;'; ftEl.innerHTML='<span style="color:var(--text3);">Financing:</span> <b>'+financingTypeLabel(tx.financingType)+'</b>'; dates.appendChild(ftEl); } 4051 if(tx.buyerAgent){ var ba=document.createElement('div'); ba.style.cssText='font-size:18px;'; ba.innerHTML='<span style="color:var(--text3);">Buyerâs agent:</span> <b>'+tx.buyerAgent+(tx.buyerAgentBrokerage?(' ('+tx.buyerAgentBrokerage+')'):'')+'</b>'; dates.appendChild(ba); } 4052 if(tx.closingDate){ 4053 var d=document.createElement('div'); d.style.cssText='font-size:18px;'; 4054 if(tx.status==='closed'){ 4055 // A closed deal is never "overdue" - show the close date instead of a countdown. 4056 d.innerHTML='<span style="color:var(--text3);">Closed:</span> <b style="color:var(--text3);">'+fd(tx.closingDate)+'</b>'; 4057 } else { 4058 var n=daysToClose; 4059 var color = n < 0 ? 'var(--danger)' : n <= 7 ? 'var(--warn)' : 'var(--lead)'; 4060 var lbl = n < 0 ? Math.abs(n)+'d overdue' : n === 0 ? 'TODAY' : n+'d to close'; 4061 d.innerHTML='<span style="color:var(--text3);">Close:</span> <b style="color:'+color+';">'+lbl+'</b>'; 4062 } 4063 dates.appendChild(d); 4064 } 4065 body.appendChild(dates); 4066 } 4067 4068 // Next steps preview 4069 if(next.length){ 4070 var nextDiv = document.createElement('div'); 4071 nextDiv.style.cssText = 'margin-top:8px;padding-top:8px;border-top:1px solid var(--border);'; 4072 var nextLbl = document.createElement('div'); 4073 nextLbl.style.cssText = 'font-size:18px;color:var(--text3);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:6px;'; 4074 nextLbl.textContent = 'Next Steps'; 4075 nextDiv.appendChild(nextLbl); 4076 next.slice(0,2).forEach(function(ns){ 4077 var row = document.createElement('div'); 4078 row.style.cssText = 'font-size:18px;color:var(--text2);padding:3px 0;display:flex;align-items:flex-start;gap:6px;'; 4079 row.innerHTML = '<span style="color:var(--accent);flex-shrink:0;">›</span>' + ns.label; 4080 nextDiv.appendChild(row); 4081 }); 4082 body.appendChild(nextDiv); 4083 } 4084 4085 card.appendChild(body); 4086 4087 // Quick "+ Follow-up" straight from the card - stopPropagation so it doesn't open the detail. 4088 var cardFoot = document.createElement('div'); 4089 cardFoot.style.cssText = 'padding:8px 14px;border-top:1px solid var(--border);'; 4090 var cardFuBtn = mkBtnEl('btn btn-g', '+ Follow-up', 'font-size:14px;padding:5px 11px;'); 4091 (function(txId){ cardFuBtn.addEventListener('click', function(e){ e.stopPropagation(); ofcTx(txId); }); })(tx.id); 4092 cardFoot.appendChild(cardFuBtn); 4093 card.appendChild(cardFoot); 4094 4095 grid.appendChild(card); 4096 }); 4097 4098 updateNbTC(); 4099} 4100 4101// ===================== ASK-CRM VOICE ASSISTANT ===================== 4102// A quick, on-the-go assistant: speak a question, it answers from your CRM data and reads it 4103// back aloud. Reuses the existing Gemini endpoint. Grounded to answer only from the data. 4104var lastAskAnswer = ''; 4105 4106// Compact snapshot of the data the assistant can reason over. Kept lean for speed/cost. 4107function askSnapshot(){ 4108 var nm = {};
4109 C.forEach(function(c){ nm[c.id] = fn(c); }); 4110 return { 4111 today: tod(), 4112 contacts: C.map(function(c){ return { 4113 name: fn(c), types: ctypes(c), stage: c.stage||'', phone: c.phone||'', email: c.email||'', 4114 property: c.property||'', price: c.price||'', lang: clang(c), notes: (c.notes||'').slice(0,500) 4115 }; }), 4116 transactions: TX.map(function(t){ return { 4117 client: nm[t.contactId]||'', address: t.address||'', side: t.type, status: t.status||'active', 4118 price: t.price||'', mls: t.mlsNum||'', lender: t.lender||'', title: t.titleCo||'', 4119 contractDate: t.contractDate||'', closingDate: t.closingDate||'', earnestDate: t.earnestDate||'', 4120 dueDiligenceDate: t.dueDiligDate||'', financingDate: t.financingDate||'', appraisalDate: t.appraisalDate||'', 4121 listCommissionPct: t.listCommissionPct, buyerCommissionPct: t.buyerCommissionPct, 4122 soldTerms: t.soldTerms||'', concessions: t.concessions||'' 4123 }; }), 4124 deadlines: D.filter(function(d){ return !dlIsClosed(d); }).map(function(d){ return { 4125 client: d.contactId!=null ? (nm[d.contactId]||'') : '(personal)', type: d.type, date: d.date, time: d.time||'' 4126 }; }), 4127 followups: F.filter(function(f){ return !f.done; }).map(function(f){ return { 4128 client: nm[f.contactId]||'', task: f.label, date: f.date, priority: f.pri||'' 4129 }; }) 4130 }; 4131} 4132 4133async function askCrm(question){ 4134 question = (question||'').trim(); 4135 if(!question) return; 4136 lastAskAnswer = ''; 4137 var ans = ge('askAnswer'), st = ge('askStatus'), acts = ge('askActions'); 4138 if(st) st.textContent = 'Thinking...'; 4139 if(ans) ans.textContent = ''; 4140 if(acts) acts.style.display = 'none'; 4141 4142 var prompt = 'You are a helpful assistant for a busy real estate agent using their CRM on the go. ' 4143 + 'Answer the question using ONLY the JSON data provided below. ' 4144 + 'If the answer is not in the data, say plainly that you do not have that information - NEVER guess or invent a number, date, name, or address. '
4145 + 'Keep the answer short and conversational, one or two sentences, because it will be read out loud. ' 4146 + 'Include the specific value (amount, date, address) when you have it. Today is ' + tod() + '.\n\n' 4147 + 'DATA:\n' + JSON.stringify(askSnapshot()) + '\n\n' 4148 + 'QUESTION: ' + question; 4149 4150 try{ 4151 var resp = await fetch('/api/claude', { 4152 method: 'POST', headers: await apiHeaders(), 4153 body: JSON.stringify({ max_tokens: 600, messages: [{ role:'user', content: prompt }] }) 4154 }); 4155 var data = await resp.json(); 4156 var answer = (data.content && data.content[0] && data.content[0].text) 4157 ? data.content[0].text.trim() 4158 : (data.error ? ('Sorry, there was an error: ' + (data.error.message||'unknown')) : 'Sorry, I could not get an answer.'); 4159 lastAskAnswer = answer; 4160 if(ans) ans.textContent = answer; 4161 if(st) st.textContent = ''; 4162 if(acts) acts.style.display = 'flex'; 4163 askSpeak(answer); 4164 }catch(e){ 4165 if(st) st.textContent = ''; 4166 if(ans) ans.textContent = 'Could not reach the assistant. Check your connection and try again.'; 4167 } 4168} 4169 4170function askSpeak(text){ 4171 try{ 4172 if(!window.speechSynthesis) return; 4173 window.speechSynthesis.cancel(); 4174 var u = new SpeechSynthesisUtterance(text); 4175 u.rate = 1.0; u.pitch = 1.0; 4176 window.speechSynthesis.speak(u); 4177 }catch(e){} 4178} 4179function askStopSpeak(){ try{ if(window.speechSynthesis) window.speechSynthesis.cancel(); }catch(e){} } 4180 4181function askListen(){ 4182 var SR = window.SpeechRecognition || window.webkitSpeechRecognition; 4183 if(!SR){ alert('Voice input needs Chrome (or your phone keyboard mic). You can type the question instead.'); if(ge('askInput')) ge('askInput').focus(); return; } 4184 askStopSpeak(); 4185 var rec = new SR(); 4186 rec.lang = 'en-US'; rec.interimResults = false; rec.maxAlternatives = 1; 4187 var mic = ge('askMic'), st = ge('askStatus'); 4188 if(mic){ mic.textContent = 'Listening...'; mic.disabled = true; } 4189 if(st) st.textContent = 'Listening - speak your question.'; 4190 rec.onresult = function(e){ 4191 var t = e.results[0][0].transcript; 4192 if(ge('askInput')) ge('askInput').value = t; 4193 askCrm(t); 4194 }; 4195 rec.onerror = function(e){ if(st) st.textContent = (e.error==='not-allowed') ? 'Microphone blocked - allow mic access, or type instead.' : 'Did not catch that - try again or type.'; }; 4196 rec.onend = function(){ if(mic){ mic.textContent = 'Speak'; mic.disabled = false; } }; 4197 try{ rec.start(); }catch(e){ if(mic){ mic.textContent='Speak'; mic.disabled=false; } } 4198} 4199 4200function openAskModal(){ 4201 lastAskAnswer = ''; 4202 if(ge('askInput')) ge('askInput').value = ''; 4203 if(ge('askAnswer')) ge('askAnswer').textContent = ''; 4204 if(ge('askStatus')) ge('askStatus').textContent = ''; 4205 if(ge('askActions')) ge('askActions').style.display = 'none'; 4206 om('askModal'); 4207} 4208 4209// ===================== IMPORT PAST SALES ===================== 4210// Historical sales -> closed transactions on the matching contact. Auto-match by client name 4211// (token match, same as the scanner); anything unmatched is assigned by hand in the review. 4212var psRows = []; 4213 4214function psGet(o, keys){ 4215 for(var k=0;k<keys.length;k++){ var v=o[keys[k]]; if(v!=null && String(v).trim()!=='') return String(v).trim(); } 4216 return ''; 4217} 4218function psMap(o){ 4219 return { 4220 clientName: psGet(o,['client name','client','name','seller','seller name','owner']), 4221 clientEmail: psGet(o,['client email','email','e-mail','email address']), 4222 mls: psGet(o,['mls#','mls #','mls','mls number','mlsnum','mls no']), 4223 address: psGet(o,['address','property address','property','street address']), 4224 soldPrice: psGet(o,['sold price','sale price','sales price','price','sold']), 4225 soldDate: psGet(o,['sold date','close date','closing date','settlement date','date']), 4226 terms: psGet(o,['sold terms','terms','financing','loan type','financing type']), 4227 concessions: psGet(o,['concessions','seller concessions','seller paid']), 4228 buyerAgent: psGet(o,["buyer's agent",'buyers agent','buyer agent','selling agent']), 4229 buyerBrokerage: psGet(o,["buyer's agent brokerage",'buyers agent brokerage','buyer agent brokerage','selling brokerage','brokerage','office']) 4230 }; 4231} 4232// Normalize a sold date to YYYY-MM-DD; keep '' if unparseable. 4233function psDate(s){ 4234 if(!s) return ''; 4235 s = String(s).trim(); 4236 if(/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0,10); 4237 var m = s.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2,4})/); 4238 if(m){ var y=m[3].length===2?('20'+m[3]):m[3]; return y+'-'+('0'+m[1]).slice(-2)+'-'+('0'+m[2]).slice(-2); } 4239 var d=new Date(s); return isNaN(d)?'':d.toISOString().slice(0,10); 4240} 4241function psRawCSV(text){ 4242 var lines=text.trim().replace(/\r\n/g,'\n').replace(/\r/g,'\n').split('\n'); 4243 if(lines.length<2) return []; 4244 var headers=csvSplit(lines[0]).map(function(h){ return h.trim().toLowerCase().replace(/"/g,''); }); 4245 var out=[]; 4246 for(var i=1;i<lines.length;i++){ if(!lines[i].trim()) continue; var cols=csvSplit(lines[i]); var o={};
4246 headers.forEach(function(h,idx){ o[h]=(cols[idx]||'').trim(); }); out.push(o); } 4247 return out; 4248} 4249function psLoadFile(file){ 4250 if(!file) return; 4251 var name=(file.name||'').toLowerCase(); 4252 var rdr=new FileReader(); 4253 if(name.slice(-4)==='.csv'){ 4254 rdr.onload=function(){ try{ psRows=psRawCSV(rdr.result).map(psMap); renderPSReview(); }catch(e){ alert('Could not read the CSV: '+e.message); } }; 4255 rdr.readAsText(file); 4256 } else { 4257 rdr.onload=function(){ 4258 try{ 4259 if(typeof XLSX==='undefined'){ alert('Excel support did not load. Try a CSV, or check your connection and reload.'); return; } 4260 var wb=XLSX.read(new Uint8Array(rdr.result),{type:'array'}); 4261 var ws=wb.Sheets[wb.SheetNames[0]]; 4262 var arr=XLSX.utils.sheet_to_json(ws,{defval:''}); 4263 psRows=arr.map(function(o){ var lo={}; Object.keys(o).forEach(function(k){ lo[String(k).trim().toLowerCase().replace(/"/g,'')]=o[k]; }); return psMap(lo); }); 4264 renderPSReview(); 4265 }catch(e){ alert('Could not read the Excel file: '+e.message); } 4266 }; 4267 rdr.readAsArrayBuffer(file); 4268 } 4269} 4270function renderPSReview(){ 4271 var wrap=ge('psReview'); if(!wrap) return; 4272 wrap.innerHTML=''; 4273 if(!psRows.length){ wrap.appendChild(mkDiv('font-size:15px;color:var(--text3);padding:14px 0;','No rows found. Check the file has a header row and data.')); ge('btnCommitPS').style.display='none'; return; } 4274 wrap.appendChild(mkDiv('font-size:14px;color:var(--text3);margin-bottom:8px;', psRows.length+' row'+(psRows.length===1?'':'s')+' found. Assign each to a contact (auto-matched where possible), or tick "new contact".')); 4275 psRows.forEach(function(r,i){ 4276 var box=document.createElement('div'); 4277 box.style.cssText='border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:8px;'; 4278 var head=r.address||'(no address)'; 4279 var meta=[r.soldPrice?('$'+String(r.soldPrice).replace(/[^0-9.,]/g,'')):'', r.soldDate?psDate(r.soldDate):'', r.mls?('MLS '+r.mls):''].filter(Boolean).join(' ⢠'); 4280 box.appendChild(mkDiv('font-size:16px;font-weight:600;color:var(--text);', head)); 4281 if(meta) box.appendChild(mkDiv('font-size:14px;color:var(--text3);margin:2px 0 4px;', meta)); 4282 var extra=[r.terms?('Terms: '+r.terms):'', r.concessions?('Concessions: '+r.concessions):'', r.buyerAgent?("Buyer's agent: "+r.buyerAgent+(r.buyerBrokerage?(' ('+r.buyerBrokerage+')'):'')):''].filter(Boolean).join(' ⢠'); 4283 if(extra) box.appendChild(mkDiv('font-size:13px;color:var(--text3);margin-bottom:6px;', extra)); 4284 4285 var assignRow=document.createElement('div'); 4286 assignRow.style.cssText='display:flex;gap:10px;align-items:center;flex-wrap:wrap;'; 4287 assignRow.appendChild(mkDiv('font-size:14px;color:var(--text2);min-width:70px;', r.clientName||'(no name in file)')); 4288 var pickWrap=document.createElement('div'); pickWrap.style.cssText='flex:1;
4288min-width:200px;'; 4289 assignRow.appendChild(pickWrap); 4290 var picker=buildContactPicker(pickWrap, 'ps_contact_'+i, 'Search / assign contact...'); 4291 var match = r.clientName ? scMatch(r.clientName) : null; 4292 if(match) picker.setContact(match); 4293 var newLbl=document.createElement('label'); newLbl.style.cssText='display:flex;align-items:center;gap:5px;font-size:13px;color:var(--text3);cursor:pointer;white-space:nowrap;'; 4294 var newCb=document.createElement('input'); newCb.type='checkbox'; newCb.id='ps_new_'+i; newCb.style.accentColor='var(--accent)'; 4295 if(!match && r.clientName) newCb.checked=false; 4296 (function(pk){ newCb.addEventListener('change', function(){ pk.input.disabled=newCb.checked; pk.input.style.opacity=newCb.checked?'0.5':'1'; }); })(picker); 4297 newLbl.appendChild(newCb); newLbl.appendChild(document.createTextNode('new contact')); 4298 assignRow.appendChild(newLbl); 4299 box.appendChild(assignRow); 4300 wrap.appendChild(box); 4301 }); 4302 ge('btnCommitPS').style.display='inline-flex'; 4303} 4304function openPastSalesImport(){ 4305 psRows=[]; 4306 if(ge('psFile')) ge('psFile').value=''; 4307 if(ge('psReview')) ge('psReview').innerHTML=''; 4308 if(ge('btnCommitPS')) ge('btnCommitPS').style.display='none'; 4309 om('impPSModal'); 4310} 4311function commitPastSalesImport(){ 4312 var made=0, skipped=0; 4313 psRows.forEach(function(r,i){ 4314 var contactId=null; 4315 var newCb=ge('ps_new_'+i); 4316 if(newCb && newCb.checked){ 4317 var parts=(r.clientName||'').trim().split(/\s+/); 4318 if(!parts[0]){ skipped++; return; } 4319 var nc={ id:Date.now()+Math.floor(Math.random()*100000)+i, first:parts[0], last:parts.slice(1).join(' '), 4320 type:'seller', email:r.clientEmail||'', emails:r.clientEmail?[{value:r.clientEmail,label:''}]:[], phones:[], addresses:[], 4321 property:r.address||'', stage:'', notes:'Created from past-sales import.', added:new Date().toISOString() }; 4322 C.push(nc); saveContact(nc); contactId=nc.id; 4323 } else { 4324 var picked=ge('ps_contact_'+i) ? ge('ps_contact_'+i).value : ''; 4325 if(!picked){ skipped++; return; } // unassigned rows are left for later 4326 contactId=parseInt(picked); 4327 } 4328 var tx={ id:Date.now()+Math.floor(Math.random()*100000)+i, contactId:contactId, type:'seller', status:'closed', 4329 address:r.address||'', price:r.soldPrice||'', mlsNum:r.mls||'', closingDate:psDate(r.soldDate), 4330 soldTerms:r.terms||'', concessions:r.concessions||'', buyerAgent:r.buyerAgent||'', buyerAgentBrokerage:r.buyerBrokerage||'', 4331 notes:'Imported past sale.', steps:{}, added:new Date().toISOString() }; 4332 TX.push(tx); saveTX(tx); made++; 4333 }); 4334 cm('impPSModal'); 4335 alert('Imported '+made+' past sale'+(made===1?'':'s')+'.' + (skipped?('\n'+skipped+' row(s) had no contact assigned and were skipped.'):'')); 4336 renderTC(); rd(); if(curDet) vc(curDet); 4337} 4338 4339// ============ INBOUND EMAIL INTELLIGENCE ============ 4340// On-demand: read a deal's recent Gmail threads and extract a plain-language digest, action items, 4341// and a suggested bilingual reply. Human-in-the-loop - nothing is applied automatically. 4342function _esc(s){ var d=document.createElement('div'); d.textContent=(s==null?'':String(s)); return d.innerHTML; } 4343function _parseJsonLoose(raw){ 4344 if(!raw) return null; 4345 var s = String(raw).replace(/```json/gi,'').replace(/```/g,'').trim(); 4346 var a = s.indexOf('{'), b = s.lastIndexOf('}'); 4347 if(a<0 || b<0 || b<=a) return null; 4348 var sub = s.substring(a, b+1); 4349 try{ return JSON.parse(sub); }catch(e){} 4350 // LLMs often emit raw control chars (newlines/tabs) inside JSON strings, which is invalid. 4351 // Strip control characters and retry so we still get usable JSON. 4352 try{ return JSON.parse(sub.replace(/[\u0000-\u001F]+/g,' ')); }catch(e){} 4353 return null; 4354} 4355function _emailIntelPrompt(corpus, tx){ 4356 return 'You are an assistant to a real estate transaction coordinator. Below are recent email ' 4357 + 'threads for ONE deal' + (tx.address ? (' at ' + tx.address) : '') + '. Read them and return ONLY a ' 4358 + 'JSON object (no markdown) with these fields:\n' 4359 + '{\n' 4360 + ' "digest": "3-6 sentence plain-English summary of what has happened and where things stand",\n' 4361 + ' "digestEs": "the same summary in Spanish",\n' 4362 + ' "updates": ["short factual bullet updates worth logging"],\n' 4363 + ' "actionItems": [{"text":"a specific next action for the coordinator","due":"YYYY-MM-DD or empty string"}],\n' 4364 + ' "draftReply": "a suggested reply to the most recent message if a reply is warranted (else empty string)",\n' 4365 + ' "draftReplyEs": "the same reply in Spanish",\n' 4366 + ' "urgent": true or false\n' 4367 + '}\n' 4368 + 'Base everything ONLY on the emails below - do not invent facts, names, dates, or amounts. If ' 4369 + 'nothing is actionable, use empty arrays and an empty draftReply.\n\nEMAILS:\n' + corpus; 4370} 4371// Build a Gmail search that finds a DEAL's emails - which mostly go to lenders/title/other agents, 4372// not the client. So we search by the things that identify the deal: property address, client 4373// name, MLS number (and the client's email as a bonus if present). 4374function _dealEmailQuery(tx, c){ 4375 var terms = []; 4376 if(tx.address){ terms.push('"' + tx.address.replace(/"/g,'') + '"'); } 4377 if(c){ var nm = fn(c); if(nm && nm.trim()) terms.push('"' + nm.replace(/"/g,'') + '"'); } 4378 if(tx.mlsNum){ var mls = String(tx.mlsNum).replace(/[^a-zA-Z0-9]/g,''); if(mls) terms.push(mls); } 4379 if(c && c.email){ terms.push('from:' + c.email, 'to:' + c.email); } 4380 if(!terms.length) return null; 4381 return '(' + terms.join(' OR ') + ') newer_than:90d'; 4382} 4383async function scanDealEmails(txId){ 4384 var tx = TX.find(function(t){ return t.id === txId; }); 4385 if(!tx) return; 4386 var c = gc(tx.contactId); 4387 var memberId = gmailState.activeMemberId || Object.keys(gmailState.connectedAccounts)[0];
4388 if(!memberId){ alert('Connect a Gmail account first (open the Gmail tab), then try again.'); return; } 4389 var q = _dealEmailQuery(tx, c); 4390 if(!q){ alert('Add a property address, client name, or MLS number to this transaction so I can find its emails.'); return; } 4391 4392 ge('emailIntelTitle').textContent = 'Email Intelligence â ' + (tx.address || (c ? fn(c) : 'Deal')); 4393 var body = ge('emailIntelBody'); 4394 body.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text3);font-size:16px;">Reading recent emailsâ¦</div>'; 4395 om('emailIntelModal'); 4396 4397 try{ 4398 var listResp = await fetch('/api/gmail-api', { method:'POST', headers: await apiHeaders(), 4399 body: JSON.stringify({ action:'inbox', memberId:memberId, query:q }) }); 4400 var listData = await listResp.json(); 4401 if(listData.error){ body.innerHTML = '<div style="padding:20px;color:var(--danger);">'+_esc(listData.error)+'</div>'; return; } 4402 var msgs = listData.messages || []; 4403 if(!msgs.length){ body.innerHTML = '<div style="padding:24px;color:var(--text3);">No emails found for this deal ('+_esc(tx.address || (c?fn(c):'deal'))+') in the last 90 days. Emails are matched by property address, client name, and MLS number.</div>'; return; } 4404 var threadIds = []; msgs.forEach(function(m){ if(m.threadId && threadIds.indexOf(m.threadId)<0) threadIds.push(m.threadId); }); 4405 threadIds = threadIds.slice(0, 6); 4406 4407 var chunks = []; 4408 for(var i=0;i<threadIds.length;i++){
4409 try{ 4410 var tResp = await fetch('/api/gmail-api', { method:'POST', headers: await apiHeaders(), 4411 body: JSON.stringify({ action:'thread', memberId:memberId, threadId:threadIds[i] }) }); 4412 var tData = await tResp.json(); 4413 (tData.messages||[]).forEach(function(mm){ 4414 var t = (mm.bodyText||'').replace(/\r/g,'').trim(); 4415 if(t) chunks.push('From: '+(mm.from||'')+'\nDate: '+(mm.date||'')+'\nSubject: '+(mm.subject||'')+'\n'+t); 4416 }); 4417 }catch(e){} 4418 } 4419 var corpus = chunks.join('\n\n---\n\n').slice(0, 15000); 4420 if(!corpus){ body.innerHTML = '<div style="padding:24px;color:var(--text3);">Found threads but couldn\'t read their contents.</div>'; return; } 4421 4422 var aiResp = await fetch('/api/claude', { method:'POST', headers: await apiHeaders(), 4423 body: JSON.stringify({ max_tokens:8192, messages:[{ role:'user', content:_emailIntelPrompt(corpus, tx) }] }) }); 4424 var aiData = await aiResp.json(); 4425 if(aiData && aiData.error){ body.innerHTML = '<div style="padding:20px;color:var(--danger);">AI error: '+_esc(aiData.error.message||JSON.stringify(aiData.error))+'</div>'; return; } 4426 var raw = (aiData.content && aiData.content[0] && aiData.content[0].text) ? aiData.content[0].text : ''; 4427 var result = _parseJsonLoose(raw); 4428 if(!result){ 4429 // Show what actually came back so the issue is diagnosable, and fall back to the raw text 4430 // as a plain summary so nothing is lost. 4431 body.innerHTML = ''; 4432 body.appendChild(mkDiv('font-size:13px;color:var(--danger);font-weight:700;margin-bottom:6px;','Could not parse the AI response â showing it raw:')); 4433 var pre=document.createElement('div'); 4434 pre.style.cssText='font-size:13px;color:var(--text2);white-space:pre-wrap;background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:10px 12px;max-height:50vh;overflow:auto;'; 4435 pre.textContent = raw ? raw : ('(empty response) â full API payload: '+JSON.stringify(aiData).slice(0,800)); 4436 body.appendChild(pre); 4437 return; 4438 } 4439 result._threads = threadIds.length; 4440 result._scope = tx.address || (c ? fn(c) : 'this deal'); 4441 result._to = (c && c.email) ? c.email : ''; 4442 renderEmailIntel(result, tx, c); 4443 }catch(e){ 4444 body.innerHTML = '<div style="padding:20px;color:var(--danger);">Error: '+_esc(e.message)+'</div>'; 4445 } 4446} 4447function renderEmailIntel(result, tx, c){ 4448 var body = ge('emailIntelBody'); body.innerHTML = ''; 4449 var lang = (c && c.lang==='es') ? 'es' : 'en'; 4450 body.appendChild(mkDiv('font-size:13px;color:var(--text3);margin-bottom:10px;', 4451 'Read '+(result._threads||0)+' recent thread'+((result._threads===1)?'':'s')+' for '+(result._scope||'this deal')+(result.urgent?' · â urgent':''))); 4452 4453 var dg = result.digest||'', dgEs = result.digestEs||'';
4454 if(dg || dgEs){ 4455 body.appendChild(mkDiv('font-size:13px;text-transform:uppercase;letter-spacing:1px;color:var(--accent);font-weight:700;margin-bottom:6px;','Summary')); 4456 var dText = mkDiv('font-size:15px;color:var(--text);line-height:1.5;white-space:pre-wrap;', (lang==='es'&&dgEs)?dgEs:dg); 4457 body.appendChild(dText); 4458 if(dg && dgEs){ 4459 var tgl=document.createElement('div'); tgl.style.cssText='margin:6px 0 4px;'; 4460 [['EN',dg],['ES',dgEs]].forEach(function(p){ var b=document.createElement('button'); b.className='nlang-btn'+(((p[0]==='ES')===(lang==='es'))?' on':''); b.textContent=p[0]; b.style.marginRight='4px'; 4461 b.addEventListener('click',function(){ dText.textContent=p[1]; tgl.querySelectorAll('.nlang-btn').forEach(function(x){x.classList.remove('on');}); b.classList.add('on'); }); tgl.appendChild(b); }); 4462 body.appendChild(tgl); 4463 } 4464 var noteBtn=document.createElement('button'); noteBtn.className='btn btn-g'; noteBtn.style.cssText='font-size:13px;padding:5px 12px;margin:8px 0 14px;'; noteBtn.textContent='Save summary as note'; 4465 noteBtn.addEventListener('click', function(){ 4466 var ups=(Array.isArray(result.updates)&&result.updates.length)?('\n\n⢠'+result.updates.join('\n⢠')):''; 4467 var nn={id:Date.now()+Math.floor(Math.random()*100000), contactId:tx.contactId, transactionId:tx.id, text:'Email summary: '+dg+ups, date:new Date().toISOString(), category:'General', textEs:(dgEs?('Resumen de correos: '+dgEs):'')}; 4468 N.push(nn); saveNote(nn); logActivity(tx.contactId,'Email summary saved'); rd(); 4469 noteBtn.textContent='Saved â'; noteBtn.disabled=true; 4470 }); 4471 body.appendChild(noteBtn); 4472 } 4473 4474 var ai = Array.isArray(result.actionItems)?result.actionItems:[]; 4475 ai = ai.filter(function(it){ return it && it.text; }); 4476 if(ai.length){ 4477 body.appendChild(mkDiv('font-size:13px;text-transform:uppercase;letter-spacing:1px;color:var(--warn);font-weight:700;margin-bottom:4px;','Action items')); 4478 ai.forEach(function(it){ 4479 var row=document.createElement('div'); row.style.cssText='display:flex;gap:8px;align-items:flex-start;padding:6px 0;border-bottom:1px solid var(--border);'; 4480 row.appendChild(mkDiv('flex:1;font-size:15px;color:var(--text);', it.text + (it.due?(' (due '+it.due+')'):''))); 4481 var fbtn=document.createElement('button'); fbtn.className='btn btn-g'; fbtn.style.cssText='font-size:12px;padding:3px 9px;flex:0 0 auto;'; fbtn.textContent='+ Follow-up'; 4482 (function(item,btn){ btn.addEventListener('click', function(){ 4483 var f={id:Date.now()+Math.floor(Math.random()*100000), contactId:tx.contactId, transactionId:tx.id, label:item.text, date:(item.due||''), pri:'warn', done:false}; 4484 F.push(f); saveFU(f); rd(); btn.textContent='Added â'; btn.disabled=true; 4485 }); })(it,fbtn); 4486 row.appendChild(fbtn); body.appendChild(row); 4487 }); 4488 } 4489 4490 var dr = result.draftReply||'', drEs = result.draftReplyEs||'';
4491 if(dr || drEs){ 4492 body.appendChild(mkDiv('font-size:13px;text-transform:uppercase;letter-spacing:1px;color:var(--buyer);font-weight:700;margin:14px 0 6px;','Suggested reply')); 4493 body.appendChild(mkDiv('font-size:14px;color:var(--text2);white-space:pre-wrap;background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:10px 12px;', (lang==='es'&&drEs)?drEs:dr)); 4494 var rowb=document.createElement('div'); rowb.style.cssText='display:flex;gap:8px;margin-top:8px;flex-wrap:wrap;'; 4495 function draftBtn(label, txt){ var b=document.createElement('button'); b.className='btn btn-p'; b.style.cssText='font-size:13px;padding:6px 12px;'; b.textContent=label; 4496 b.addEventListener('click', function(){ cm('emailIntelModal'); openCompose({ to:(result._to||''), subject:'Re:'+(tx.address?(' '+tx.address):' your transaction'), body:txt }); }); return b; } 4497 if(dr) rowb.appendChild(draftBtn('Reply in English', dr)); 4498 if(drEs) rowb.appendChild(draftBtn('Reply in Spanish', drEs)); 4499 body.appendChild(rowb); 4500 } 4501 4502 if(!dg && !ai.length && !dr && !drEs){ body.appendChild(mkDiv('font-size:15px;color:var(--text3);padding:8px 0;','Nothing notable found in the recent emails.')); } 4503} 4504 4505// ============ DOCUMENT COMPLETENESS CHECKER ============ 4506// Deterministic: each deal type has a required-documents list. A requirement is satisfied when a 4507// scanned document matches its type keywords, or when the user manually marks it present. The user 4508// can mark anything N/A. No AI at render time - the scanner already classified the docs on import. 4509function requiredDocs(tx){ 4510 if(tx.category === 'commercial_lease'){ 4511 return [ 4512 {key:'lease', label:'Signed Lease', kw:['lease']}, 4513 {key:'guaranty', label:'Guaranty (if required)', kw:['guaranty','guarantee']}, 4514 {key:'estoppel', label:'Estoppel Certificate', kw:['estoppel']}, 4515 {key:'coi', label:'Certificate of Insurance', kw:['insurance','coi']}, 4516 {key:'snda', label:'SNDA (if lender requires)', kw:['snda','subordination']} 4517 ]; 4518 } 4519 var isBuyer = (tx.type === 'buyer'); 4520 var list = [ 4521 {key:'repc', label:'Purchase Contract (REPC)', kw:['repc','purchase contract','real estate purchase']}, 4522 {key:'agency', label:'Agency / Brokerage Agreement', kw:['agency','brokerage agreement','buyer-broker','buyer broker','listing agreement']}, 4523 {key:'spcd', label:'Seller Property Condition Disclosure', kw:['seller disclosure','property condition','spcd']}, 4524 {key:'lead', label:'Lead-Based Paint Disclosure (pre-1978)', kw:['lead']}, 4525 {key:'title', label:'Title Commitment', kw:['title']}, 4526 {key:'appraisal', label:'Appraisal', kw:['appraisal']}, 4527 {key:'settlement', label:'Settlement Statement / Closing Disclosure', kw:['settlement','closing disclosure']} 4528 ]; 4529 if(isBuyer) list.splice(4, 0, {key:'inspection', label:'Inspection Report', kw:['inspection']}); 4530 return list; 4531} 4532// True if any document on this transaction matches the requirement's keywords (by classified type). 4533function _docPresentFor(tx, kw){ 4534 var docs = DOCS.filter(function(d){ return String(d.transaction_id)===String(tx.id); }); 4535 for(var i=0;i<docs.length;i++){ 4536 var d = docs[i]; 4537 var hay = ((d.doc_type||'') + ' ' + ((d.extracted && d.extracted.docType)||'') + ' ' + (d.file_name||'')).toLowerCase(); 4538 for(var j=0;j<kw.length;j++){ if(hay.indexOf(kw[j]) >= 0) return true; } 4539 } 4540 return false; 4541} 4542function computeDocChecklist(tx){ 4543 var over = tx.docChecklist || {}; 4544 return requiredDocs(tx).map(function(r){ 4545 var status, how=''; 4546 if(over[r.key] === 'na'){ status='na'; } 4547 else if(_docPresentFor(tx, r.kw)){ status='present'; how='scanned'; } 4548 else if(over[r.key] === 'present'){ status='present'; how='added'; } 4549 else { status='missing'; } 4550 return { key:r.key, label:r.label, status:status, how:how }; 4551 }); 4552} 4553function setDocCheck(txId, key, status){ 4554 var tx = TX.find(function(t){ return t.id === txId; }); if(!tx) return; 4555 tx.docChecklist = tx.docChecklist || {}; 4556 if(status === null){ delete tx.docChecklist[key]; } else { tx.docChecklist[key] = status; } 4557 // Marking the Appraisal document present or N/A completes the Appraisal checklist item (and so 4558 // clears its deadline). Only auto-checks; a later clear won't uncheck a completed step. 4559 if(key === 'appraisal' && (status === 'present' || status === 'na')){ 4560 tx.steps = tx.steps || {}; 4561 var _ak = (tx.type === 'seller') ? 's3_appr' : 'b3_appr'; 4562 tx.steps[_ak] = true; 4563 } 4564 saveTX(tx); openTCDetail(txId); rd(); if(curPage==='deadlines') rdl(); 4565} 4566function renderDocChecklist(tx){ 4567 var items = computeDocChecklist(tx); 4568 var present = items.filter(function(x){ return x.status==='present'; }).length; 4569 var missing = items.filter(function(x){ return x.status==='missing'; }).length; 4570 var na = items.filter(function(x){ return x.status==='na'; }).length; 4571 var relevant = items.length - na; 4572 4573 var sec = document.createElement('div'); 4574 sec.style.cssText = 'padding:14px 20px;border-bottom:1px solid var(--border);'; 4575 var hdColor = missing ? 'var(--warn)' : 'var(--lead)'; 4576 sec.appendChild(mkDiv('font-size:14px;text-transform:uppercase;letter-spacing:1.2px;color:'+hdColor+';font-weight:700;margin-bottom:10px;', 4577 'ð Document Checklist â ' + present + '/' + relevant + ' present' + (missing ? (' · ' + missing + ' missing') : ' · complete â'))); 4578
4579 items.forEach(function(it){ 4580 var row = document.createElement('div'); 4581 row.style.cssText = 'display:flex;gap:10px;align-items:center;padding:6px 0;border-bottom:1px solid var(--border);flex-wrap:wrap;'; 4582 var color = it.status==='present' ? 'var(--lead)' : (it.status==='na' ? 'var(--text3)' : 'var(--danger)'); 4583 row.appendChild(mkDiv('flex:0 0 auto;width:11px;height:11px;border-radius:50%;background:'+color+';','')); 4584 var lbl = mkDiv('flex:1;font-size:15px;color:'+(it.status==='na'?'var(--text3)':'var(--text)')+';', it.label + (it.how==='scanned'?' (scanned)':(it.how==='added'?' (marked)':''))); 4585 if(it.status==='na') lbl.style.textDecoration='line-through'; 4586 row.appendChild(lbl); 4587 var acts = document.createElement('div'); acts.style.cssText='display:flex;gap:6px;flex-shrink:0;'; 4588 function btn(text, fn){ var b=document.createElement('button'); b.textContent=text; b.style.cssText='background:var(--surface2);border:1px solid var(--border);border-radius:5px;padding:2px 9px;font-size:12px;color:var(--text2);cursor:pointer;font-family:DM Sans,sans-serif;'; b.addEventListener('click',fn); return b; } 4589 if(it.status==='missing'){ 4590 acts.appendChild(btn('Mark present', function(){ setDocCheck(tx.id, it.key, 'present'); })); 4591 acts.appendChild(btn('N/A', function(){ setDocCheck(tx.id, it.key, 'na'); })); 4592 } else if(it.status==='present' && it.how==='added'){ 4593 acts.appendChild(btn('Undo', function(){ setDocCheck(tx.id, it.key, null); })); 4594 } else if(it.status==='na'){ 4595 acts.appendChild(btn('Restore', function(){ setDocCheck(tx.id, it.key, null); })); 4596 } 4597 row.appendChild(acts); 4598 sec.appendChild(row); 4599 }); 4600 return sec; 4601} 4602 4603// ============ PER-DEAL ACTIVITY TIMELINE ============ 4604// One chronological view of everything that happened on a transaction, aggregated from the data 4605// already stored: notes, document field changes (addenda), documents added, follow-ups, deadlines, 4606// and the deal's creation. Deterministic - no new storage. 4607function _tlTs(d){ if(d==null) return 0; var t = new Date(d).getTime(); if(!isNaN(t)) return t; if(typeof d==='number') return d; return 0; } 4608function _tlDate(t){ if(!t) return ''; try{ return fd(new Date(t).toISOString().slice(0,10)); }catch(e){ return ''; } } 4609function computeTxTimeline(tx){ 4610 var items = []; 4611 N.filter(function(n){ return String(n.transactionId)===String(tx.id); }).forEach(function(n){ 4612 items.push({ t:_tlTs(n.date), icon:'ð', label:'Note', text:String(n.text||'').slice(0,220) }); 4613 }); 4614 CH.filter(function(h){ return String(h.transactionId)===String(tx.id); }).forEach(function(h){ 4615 var by = (h.addendumNo!=null) ? (' · Addendum #'+h.addendumNo) : ''; 4616 items.push({ t:_tlTs(h.appliedAt), icon:'ð', label:'Change', text:_riskFieldLabel(h.field)+': '+(h.oldValue||'â')+' â '+(h.newValue||'â')+by }); 4617 }); 4618 DOCS.filter(function(d){ return String(d.transaction_id)===String(tx.id); }).forEach(function(d){ 4619 items.push({ t:_tlTs(d.created_at || (typeof d.id==='number'?d.id:null)), icon:'ð', label:'Document', text:(d.file_name||'Document')+(d.doc_type?(' ('+d.doc_type+')'):'') }); 4620 }); 4621 F.filter(function(f){ return String(f.transactionId)===String(tx.id); }).forEach(function(f){ 4622 items.push({ t:_tlTs(f.date), icon:(f.done?'â ':'â¬'), label:'Follow-up', text:(f.label||'Follow-up')+(f.done?' â done':'') }); 4623 }); 4624 D.filter(function(d){ return String(d.transactionId)===String(tx.id); }).forEach(function(d){ 4625 items.push({ t:_tlTs(d.date), icon:'â°', label:'Deadline', text:d.type }); 4626 }); 4627 var createdT = tx.added ? _tlTs(tx.added) : (typeof tx.id==='number' ? tx.id : 0); 4628 if(createdT) items.push({ t:createdT, icon:'â', label:'Created', text:'Transaction created'+(tx.address?(' â '+tx.address):'') }); 4629 items.sort(function(a,b){ return b.t - a.t; }); // newest first 4630 return items; 4631} 4632function renderTxTimeline(tx){ 4633 var items = computeTxTimeline(tx); 4634 var sec = document.createElement('div'); 4635 sec.style.cssText = 'padding:14px 20px;border-bottom:1px solid var(--border);'; 4636 sec.appendChild(mkDiv('font-size:14px;text-transform:uppercase;letter-spacing:1.2px;color:var(--text2);font-weight:700;margin-bottom:10px;', 'ð Activity Timeline')); 4637 if(!items.length){ sec.appendChild(mkDiv('font-size:15px;color:var(--text3);', 'No activity yet.')); return sec; } 4638 var SHOW = 8;
4639 items.forEach(function(it, i){ 4640 var row = document.createElement('div'); 4641 row.style.cssText = 'display:flex;gap:10px;align-items:flex-start;padding:7px 0;border-bottom:1px solid var(--border);'; 4642 if(i >= SHOW){ row.className = 'tl-more'; row.style.display = 'none'; } 4643 row.appendChild(mkDiv('flex:0 0 auto;font-size:16px;line-height:1.3;', it.icon)); 4644 var mid = mkDiv('flex:1;min-width:0;'); 4645 mid.appendChild(mkDiv('font-size:15px;color:var(--text);word-break:break-word;white-space:pre-wrap;', it.text)); 4646 mid.appendChild(mkDiv('font-size:12px;color:var(--text3);margin-top:1px;', it.label + (it.t ? (' · ' + _tlDate(it.t)) : ''))); 4647 row.appendChild(mid); 4648 sec.appendChild(row); 4649 }); 4650 if(items.length > SHOW){ 4651 var more = mkDiv('font-size:13px;color:var(--accent);cursor:pointer;margin-top:9px;user-select:none;', 'Show all ' + items.length + ' activities'); 4652 more.addEventListener('click', function(){ sec.querySelectorAll('.tl-more').forEach(function(r){ r.style.display = ''; }); more.style.display = 'none'; }); 4653 sec.appendChild(more); 4654 } 4655 return sec; 4656} 4657 4658// Read-only panel of a commercial lease's key terms, shown on the transaction detail. 4659function renderLeasePanel(tx){ 4660 var L = tx.details || {}; 4661 var basis = function(b){ return {per_sqft_month:'/SF/mo', per_sqft_year:'/SF/yr', monthly:'/mo', annual:'/yr'}[b] || ''; }; 4662 var money = function(v){ return (v===''||v==null) ? '' : (isNaN(v) ? String(v) : '$'+Number(v).toLocaleString()); }; 4663 var sec = document.createElement('div'); 4664 sec.style.cssText = 'padding:14px 20px;border-bottom:1px solid var(--border);'; 4665 var hd = mkDiv('font-size:14px;text-transform:uppercase;letter-spacing:1.2px;color:var(--seller);font-weight:700;margin-bottom:10px;', 'Commercial Lease Terms'); 4666 sec.appendChild(hd); 4667 var grid = document.createElement('div'); 4668 grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:11px 20px;'; 4669 var rows = [ 4670 ['Landlord', L.landlord], 4671 ['Tenant', L.tenant], 4672 ['Rentable SF', (L.rentableSqft!==''&&L.rentableSqft!=null) ? Number(L.rentableSqft).toLocaleString()+' SF' : ''], 4673 ['Base Rent', (L.baseRent!==''&&L.baseRent!=null) ? (money(L.baseRent)+' '+basis(L.rentBasis)) : ''], 4674 ['Lease Type', L.leaseType], 4675 ['CAM', (L.camAmount!==''&&L.camAmount!=null) ? (money(L.camAmount)+' '+basis(L.camBasis)) : ''], 4676 ['Term', (L.leaseTermMonths!==''&&L.leaseTermMonths!=null) ? (L.leaseTermMonths+' months') : ''], 4677 ['Commencement', L.commencementDate ? fd(L.commencementDate) : ''], 4678 ['Rent Commencement', L.rentCommencementDate ? fd(L.rentCommencementDate) : ''], 4679 ['Expiration', L.expirationDate ? fd(L.expirationDate) : ''], 4680 ['Escalation', L.escalation], 4681 ['TI Allowance', L.tiAllowance], 4682 ['Security Deposit', L.securityDeposit], 4683 ['Renewal Options', L.renewalOptions], 4684 ['Option Deadline', L.renewalOptionDeadline ? fd(L.renewalOptionDeadline) : ''], 4685 ['Guaranty', L.guaranty] 4686 ]; 4687 var any = false; 4688 rows.forEach(function(rw){ 4689 if(rw[1]===''||rw[1]==null) return; 4690 any = true; 4691 var cell = document.createElement('div'); 4692 cell.appendChild(mkDiv('font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);', rw[0])); 4693 cell.appendChild(mkDiv('font-size:16px;color:var(--text);margin-top:2px;', String(rw[1]))); 4694 grid.appendChild(cell); 4695 }); 4696 if(!any) grid.appendChild(mkDiv('font-size:15px;color:var(--text3);', 'No lease terms captured yet.')); 4697 sec.appendChild(grid); 4698 return sec; 4699} 4700
4701// ============ OFFERS â multiple competing offers on a seller's listing ============ 4702// Offers live on the seller transaction (tx.details.offers[]). They are inert: adding/comparing 4703// offers never creates deadlines, follow-ups, or a pipeline move. Only ACCEPTING one promotes it 4704// into the contract (deal goes Under Contract, terms copied onto the tx, deadlines generated). 4705var OFFER_FIN=[{value:'seller',label:'Seller financing'},{value:'conventional',label:'Conventional'},{value:'fha',label:'FHA'},{value:'va',label:'VA'},{value:'cash',label:'Cash'}]; 4706var OFFER_STATUS=[{value:'active',label:'Active'},{value:'countered',label:'Countered'},{value:'accepted',label:'Accepted'},{value:'rejected',label:'Rejected'},{value:'withdrawn',label:'Withdrawn'}]; 4707function _finLabel(v){ var f=OFFER_FIN.find(function(x){return x.value===v;}); return f?f.label:(v||'â'); } 4708function txOffers(tx){ return (tx && tx.details && Array.isArray(tx.details.offers)) ? tx.details.offers : []; } 4709function saveOffers(tx, offs){ tx.details=tx.details||{}; tx.details.offers=offs; saveTX(tx); } 4710// Commission % for an offer: the offer's own, else the listing's total (list + buyer side). 4711function offerCommissionPct(o, tx){ 4712 var c=invNum(o.commissionPct); if(c>0) return c; 4713 return invNum(tx&&tx.listCommissionPct)+invNum(tx&&tx.buyerCommissionPct); 4714} 4715// Estimated CASH to the seller at closing = cash basis (full price, or just the down for seller 4716// financing) minus commission (on full price), concessions, and other seller costs (title/payoff/etc.). 4717function offerNetToSeller(o, tx){ 4718 var price=invNum(o.price); 4719 var cashBasis=(o.financingType==='seller') ? invNum((o.sf||{}).down) : price; 4720 var commission=price*offerCommissionPct(o,tx)/100; 4721 return cashBasis - commission - invNum(o.concessions) - invNum(o.sellerCosts); 4722} 4723// For seller financing, the balance carried as a note (a receivable, not cash at close). 4724function offerFinancedBalance(o){ return (o.financingType==='seller') ? Math.max(0, invNum(o.price)-invNum((o.sf||{}).down)) : 0; } 4725function openOfferForm(txId, offer){ 4726 var tx=TX.find(function(t){return t.id===txId;}); if(!tx) return; 4727 var sf=(offer&&offer.sf)||{}; 4728 var init = offer ? Object.assign({}, offer, {sfDown:sf.down,sfRate:sf.rate,sfTermMonths:sf.termMonths,sfBalloonMonths:sf.balloonMonths,sfMonthly:sf.monthly}) 4729 : { status:'active', financingType:'conventional', offerDate: tod() }; 4730 invOpenForm(offer?'Edit offer':'Add offer', [ 4731 {key:'buyer',label:'Buyer',placeholder:'Buyer name'}, 4732 {key:'buyerAgent',label:"Buyer's agent"}, 4733 {key:'offerDate',label:'Offer date',type:'date'}, 4734 {key:'price',label:'Offer price',type:'number'}, 4735 {key:'financingType',label:'Financing',type:'select',options:OFFER_FIN,def:'conventional'}, 4736 {key:'earnest',label:'Earnest money',type:'number'}, 4737 {key:'closingDate',label:'Proposed closing',type:'date'}, 4738 {key:'dueDiligenceDate',label:'Due diligence deadline',type:'date'}, 4739 {key:'financingDate',label:'Financing deadline',type:'date'}, 4740 {key:'appraisalDate',label:'Appraisal deadline',type:'date'}, 4741 {key:'concessions',label:'Seller concessions ($)',type:'number'}, 4742 {key:'commissionPct',label:'Total commission (%)',type:'number'}, 4743 {key:'sellerCosts',label:'Other seller costs ($) â title, payoff, etc.',type:'number'}, 4744 {key:'sfDown',label:'Seller-fin: down payment',type:'number'}, 4745 {key:'sfRate',label:'Seller-fin: interest rate %',type:'number'}, 4746 {key:'sfTermMonths',label:'Seller-fin: term (months)',type:'number'}, 4747 {key:'sfBalloonMonths',label:'Seller-fin: balloon (months)',type:'number'}, 4748 {key:'sfMonthly',label:'Seller-fin: monthly payment',type:'number'}, 4749 {key:'contingencies',label:'Contingencies',type:'textarea'}, 4750 {key:'status',label:'Status',type:'select',options:OFFER_STATUS,def:'active'}, 4751 {key:'notes',label:'Notes',type:'textarea'} 4752 ], init, function(v){ 4753 var rec = offer || { id: Date.now()+Math.floor(Math.random()*100000) }; 4754 rec.buyer=(v.buyer||'').trim(); rec.buyerAgent=(v.buyerAgent||'').trim(); rec.offerDate=v.offerDate||''; 4755 rec.price=v.price; rec.financingType=v.financingType||'conventional'; rec.earnest=v.earnest; 4756 rec.closingDate=v.closingDate; rec.dueDiligenceDate=v.dueDiligenceDate; rec.financingDate=v.financingDate; rec.appraisalDate=v.appraisalDate; 4757 rec.concessions=v.concessions; rec.commissionPct=v.commissionPct; rec.sellerCosts=v.sellerCosts; 4758 rec.contingencies=(v.contingencies||'').trim(); rec.status=v.status||'active'; rec.notes=(v.notes||'').trim(); 4759 rec.sf={down:v.sfDown,rate:v.sfRate,termMonths:v.sfTermMonths,balloonMonths:v.sfBalloonMonths,monthly:v.sfMonthly}; 4760 var offs=txOffers(tx).slice(); var i=offs.findIndex(function(o){return String(o.id)===String(rec.id);}); 4761 if(i>=0) offs[i]=rec; else offs.push(rec); 4762 saveOffers(tx, offs); openTCDetail(txId); 4763 }); 4764} 4765function deleteOffer(txId, offerId){ 4766 var tx=TX.find(function(t){return t.id===txId;}); if(!tx) return; 4767 if(!confirm('Delete this offer?')) return; 4768 saveOffers(tx, txOffers(tx).filter(function(o){return String(o.id)!==String(offerId);})); 4769 openTCDetail(txId); 4770} 4771function setOfferStatus(txId, offerId, status){ 4772 var tx=TX.find(function(t){return t.id===txId;}); if(!tx) return; 4773 var offs=txOffers(tx).slice(); var o=offs.find(function(x){return String(x.id)===String(offerId);}); if(!o) return; 4774 o.status=status; saveOffers(tx, offs); openTCDetail(txId); 4775} 4776function acceptOffer(txId, offerId){ 4777 var tx=TX.find(function(t){return t.id===txId;}); if(!tx) return; 4778 var offs=txOffers(tx).slice(); var o=offs.find(function(x){return String(x.id)===String(offerId);}); if(!o) return; 4779 if(!confirm('Accept this offer?\n\nThe deal moves to Under Contract, the offer terms are copied onto the transaction, and deadlines are generated. The other offers are marked Rejected (you can change any status later).')) return; 4780 if(o.price) tx.price=o.price; 4781 if(o.closingDate) tx.closingDate=o.closingDate; 4782 if(o.dueDiligenceDate) tx.dueDiligDate=o.dueDiligenceDate; 4783 if(o.financingDate) tx.financingDate=o.financingDate; 4784 if(o.appraisalDate) tx.appraisalDate=o.appraisalDate; 4785 if(o.financingType) tx.financingType=o.financingType; 4786 if(!tx.contractDate) tx.contractDate=o.offerDate||tod(); 4787 if(o.financingType==='seller'){ var s=o.sf||{}; tx.details=tx.details||{}; tx.details.sf=Object.assign({}, tx.details.sf||{}, {down:s.down,rate:s.rate,term:s.termMonths,balloon:s.balloonMonths,monthly:s.monthly,amount:(invNum(o.price)-invNum(s.down))}); }
4788 offs.forEach(function(x){ x.status = (String(x.id)===String(offerId)) ? 'accepted' : (x.status==='withdrawn' ? 'withdrawn' : 'rejected'); }); 4789 tx.details=tx.details||{}; tx.details.offers=offs; tx.details.acceptedOfferId=offerId; 4790 saveTX(tx); // materializeWorkflowTasks fires here -> deadlines from the copied dates 4791 var c=gc(tx.contactId); if(c && c.stage!=='Under Contract' && c.stage!=='Closed') ss(tx.contactId,'Under Contract'); 4792 renderTC(); rd(); openTCDetail(txId); 4793} 4794function renderOffersSection(tx){ 4795 var sec=document.createElement('div'); sec.style.cssText='padding:14px 20px;border-bottom:1px solid var(--border);'; 4796 var offs=txOffers(tx); 4797 var hd=document.createElement('div'); hd.style.cssText='display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px;'; 4798 hd.appendChild(mkDivSafe('font-size:14px;text-transform:uppercase;letter-spacing:1px;color:var(--accent);font-weight:700;','ð Offers'+(offs.length?(' ('+offs.length+')'):''))); 4799 var addB=document.createElement('button'); addB.className='btn btn-p'; addB.style.cssText='font-size:13px;padding:5px 12px;'; addB.textContent='+ Add offer'; 4800 (function(txId){ addB.addEventListener('click',function(){ openOfferForm(txId); }); })(tx.id); 4801 hd.appendChild(addB); sec.appendChild(hd); 4802 if(!offs.length){ sec.appendChild(mkDivSafe('font-size:14px;color:var(--text3);','No offers yet. Add competing offers here to compare them â this does <b>not</b> put the deal under contract.')); return sec; } 4803 var stColors={active:'var(--lead)',accepted:'var(--seller)',countered:'var(--accent)',rejected:'var(--text3)',withdrawn:'var(--text3)'}; 4804 var wrap=document.createElement('div'); wrap.style.cssText='overflow-x:auto;'; 4805 var tbl=document.createElement('table'); tbl.style.cssText='border-collapse:collapse;font-size:13px;min-width:100%;'; 4806 var thead='<tr><th style="text-align:left;padding:6px 10px;color:var(--text3);font-weight:500;min-width:120px;">Offer</th>'; 4807 offs.forEach(function(o){ thead+='<th style="text-align:left;padding:6px 10px;border-left:1px solid var(--border);min-width:150px;"><div style="font-weight:700;">'+_esc(o.buyer||'(buyer)')+'</div><div style="font-size:11px;color:'+(stColors[o.status]||'var(--text3)')+';">'+_esc(String(o.status||'active').toUpperCase())+'</div></th>'; }); 4808 thead+='</tr>'; 4809 var rows=[ 4810 ["Buyer's agent",function(o){return _esc(o.buyerAgent||'â');}], 4811 ['Offer date',function(o){return o.offerDate?_esc(fd(o.offerDate)):'â';}], 4812 ['Price',function(o){return o.price?_esc(invMoney(invNum(o.price))):'â';}], 4813 ['Financing',function(o){return _esc(_finLabel(o.financingType));}], 4814 ['Earnest',function(o){return o.earnest?_esc(invMoney(invNum(o.earnest))):'â';}], 4815 ['Concessions',function(o){return o.concessions?_esc(invMoney(invNum(o.concessions))):'â';}], 4816 ['Commission %',function(o){var p=offerCommissionPct(o,tx);return p?_esc(p+'%'):'â';}], 4817 ['Other seller costs',function(o){return o.sellerCosts?_esc(invMoney(invNum(o.sellerCosts))):'â';}], 4818 ['Closing',function(o){return o.closingDate?_esc(fd(o.closingDate)):'â';}], 4819 ['Due diligence',function(o){return o.dueDiligenceDate?_esc(fd(o.dueDiligenceDate)):'â';}], 4820 ['Financing deadline',function(o){return o.financingDate?_esc(fd(o.financingDate)):'â';}], 4821 ['Appraisal',function(o){return o.appraisalDate?_esc(fd(o.appraisalDate)):'â';}], 4822 ['SF down',function(o){return (o.sf&&o.sf.down)?_esc(invMoney(invNum(o.sf.down))):'â';}], 4823 ['SF rate',function(o){return (o.sf&&o.sf.rate)?_esc(o.sf.rate+'%'):'â';}], 4824 ['SF term',function(o){return (o.sf&&o.sf.termMonths)?_esc(o.sf.termMonths+' mo'):'â';}], 4825 ['SF balloon',function(o){return (o.sf&&o.sf.balloonMonths)?_esc(o.sf.balloonMonths+' mo'):'â';}], 4826 ['SF monthly',function(o){return (o.sf&&o.sf.monthly)?_esc(invMoney(invNum(o.sf.monthly))):'â';}], 4827 ['Contingencies',function(o){return _esc(o.contingencies||'â');}], 4828 ['Notes',function(o){return _esc(o.notes||'â');}] 4829 ]; 4830 // Highlighted net-to-seller row: best (highest cash at close) shown bold green. Seller-financing 4831 // shows the carried note separately since it isn't cash at the table. 4832 var _nets=offs.map(function(o){ return offerNetToSeller(o,tx); }); 4833 var _maxNet=Math.max.apply(null, _nets); 4834 var netRow='<tr style="background:var(--surface2);"><td style="padding:8px 10px;border-top:2px solid var(--border);font-weight:700;white-space:nowrap;">Est. net to seller<div style="font-size:11px;color:var(--text3);font-weight:400;">cash at close</div></td>';
4835 offs.forEach(function(o,i){ 4836 var n=_nets[i]; var best=(n===_maxNet && offs.length>1); 4837 var fb=offerFinancedBalance(o); 4838 netRow+='<td style="padding:8px 10px;border-top:2px solid var(--border);border-left:1px solid var(--border);">' 4839 + '<div style="font-weight:700;color:'+(best?'var(--lead)':'var(--text)')+';">'+_esc(invMoney(n))+(best?' â ':'')+'</div>' 4840 + (fb>0?('<div style="font-size:11px;color:var(--text3);">+ note '+_esc(invMoney(fb))+((o.sf&&o.sf.rate)?(' @ '+_esc(o.sf.rate)+'%'):'')+'</div>'):'') 4841 + '</td>'; 4842 }); 4843 netRow+='</tr>'; 4844 var bodyHtml=netRow; 4845 rows.forEach(function(r){ 4846 bodyHtml+='<tr><td style="padding:6px 10px;color:var(--text3);border-top:1px solid var(--border);white-space:nowrap;">'+r[0]+'</td>'; 4847 offs.forEach(function(o){ bodyHtml+='<td style="padding:6px 10px;border-top:1px solid var(--border);border-left:1px solid var(--border);">'+r[1](o)+'</td>'; }); 4848 bodyHtml+='</tr>'; 4849 }); 4850 tbl.innerHTML='<thead>'+thead+'</thead><tbody>'+bodyHtml+'</tbody>'; 4851 wrap.appendChild(tbl); sec.appendChild(wrap); 4852 var acts=document.createElement('div'); acts.style.cssText='display:flex;flex-wrap:wrap;gap:16px;margin-top:12px;'; 4853 offs.forEach(function(o){ 4854 var col=document.createElement('div'); col.style.cssText='display:flex;flex-direction:column;gap:4px;min-width:150px;'; 4855 col.appendChild(mkDivSafe('font-size:12px;color:var(--text3);', _esc(o.buyer||'(buyer)'))); 4856 var brow=document.createElement('div'); brow.style.cssText='display:flex;gap:5px;flex-wrap:wrap;'; 4857 function ob(lbl,cls,fn){ var b=document.createElement('button'); b.className='btn '+cls; b.style.cssText='font-size:12px;padding:3px 9px;'; b.textContent=lbl; b.addEventListener('click',fn); return b; } 4858 if(o.status!=='accepted') brow.appendChild(ob('Accept','btn-p',(function(id){return function(){acceptOffer(tx.id,id);};})(o.id))); 4859 brow.appendChild(ob('Edit','btn-g',(function(id){return function(){openOfferForm(tx.id,txOffers(tx).find(function(x){return String(x.id)===String(id);}));};})(o.id))); 4860 if(o.status!=='rejected') brow.appendChild(ob('Reject','btn-g',(function(id){return function(){setOfferStatus(tx.id,id,'rejected');};})(o.id))); 4861 brow.appendChild(ob('Delete','btn-g',(function(id){return function(){deleteOffer(tx.id,id);};})(o.id))); 4862 col.appendChild(brow); acts.appendChild(col); 4863 }); 4864 sec.appendChild(acts); 4865 return sec; 4866} 4867function openTCDetail(id){ 4868 curTx = id; 4869 var tx = TX.find(function(t){ return t.id === id; }); 4870 if(!tx) return; 4871 var c = gc(tx.contactId); 4872 4873 ge('tcDetTitle').textContent = tx.address || 'Transaction'; 4874 if(tx.category === 'commercial_lease'){ 4875 var _L = tx.details || {}; 4876 var _basis = {per_sqft_month:'/SF/mo', per_sqft_year:'/SF/yr', monthly:'/mo', annual:'/yr'}[_L.rentBasis] || ''; 4877 var _rent = (_L.baseRent!=='' && _L.baseRent!=null) ? ('$'+Number(_L.baseRent).toLocaleString()+' '+_basis) : ''; 4878 ge('tcDetSub').textContent = 'COMMERCIAL LEASE' 4879 + (_L.leaseType ? ' · '+_L.leaseType : '') 4880 + (_rent ? ' · '+_rent : '') 4881 + (_L.expirationDate ? ' · Exp: '+fd(_L.expirationDate) : ''); 4882 } else { 4883 ge('tcDetSub').textContent = (tx.price ? tx.price + ' | ' : '') + tx.type.toUpperCase() + (tx.closingDate ? ' | Close: ' + fd(tx.closingDate) : ''); 4884 } 4885 4886 var body = ge('tcDetBody'); 4887 body.innerHTML = ''; 4888 4889 // Status row - mark closed / reopen 4890 var stRow = document.createElement('div'); 4891 stRow.style.cssText = 'padding:10px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:10px;'; 4892 var stLbl = document.createElement('div'); 4893 stLbl.style.cssText = 'font-size:15px;'; 4894 var isClosed = tx.status === 'closed'; 4895 stLbl.innerHTML = '<span style="color:var(--text3);">Status:</span> <b style="color:'+(isClosed?'var(--text3)':'var(--lead)')+';">'+(isClosed?'Closed':'Active')+'</b>'; 4896 var stBtn = document.createElement('button'); 4897 stBtn.className = 'btn ' + (isClosed ? 'btn-g' : 'btn-p'); 4898 stBtn.style.cssText = 'font-size:15px;padding:6px 14px;'; 4899 stBtn.textContent = isClosed ? 'Reopen' : 'Mark Closed'; 4900 (function(txId){ stBtn.addEventListener('click', function(){ 4901 var t = TX.find(function(x){ return x.id===txId; }); 4902 if(!t) return; 4903 t.status = (t.status==='closed') ? 'active' : 'closed'; 4904 saveTX(t); 4905 // Keep the Pipeline in step with the transaction. Only move the client to Closed once 4906 // they have no other open deals - otherwise an active transaction would vanish from the board. 4907 if(t.contactId){ 4908 if(t.status==='closed'){ 4909 var stillOpen = TX.some(function(x){ 4910 return String(x.contactId)===String(t.contactId) && String(x.id)!==String(t.id) && x.status!=='closed'; 4911 }); 4912 if(!stillOpen) ss(t.contactId, 'Closed'); 4913 } else { 4914 var oc = gc(t.contactId); 4915 if(oc && oc.stage==='Closed') ss(t.contactId, 'Under Contract'); 4916 } 4917 } 4918 renderTC(); updateNbTC(); rd(); 4919 openTCDetail(txId); 4920 }); })(id); 4921 var stRight = document.createElement('div'); stRight.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;'; 4922 var detFuBtn = document.createElement('button'); 4923 detFuBtn.className = 'btn btn-g'; detFuBtn.style.cssText = 'font-size:15px;padding:6px 12px;'; 4924 detFuBtn.textContent = '+ Follow-up'; 4925 (function(txId){ detFuBtn.addEventListener('click', function(){ ofcTx(txId); }); })(id); 4926 var detEmailBtn = document.createElement('button'); 4927 detEmailBtn.className = 'btn btn-g'; detEmailBtn.style.cssText = 'font-size:15px;padding:6px 12px;'; 4928 detEmailBtn.textContent = 'â Scan Emails'; 4929 detEmailBtn.title = 'Read this deal\'s recent emails and extract updates, action items, and a draft reply'; 4930 (function(txId){ detEmailBtn.addEventListener('click', function(){ scanDealEmails(txId); }); })(id); 4931 stRight.appendChild(detEmailBtn); stRight.appendChild(detFuBtn); stRight.appendChild(stBtn); 4932 stRow.appendChild(stLbl); stRow.appendChild(stRight); 4933 body.appendChild(stRow); 4934 4935 // Offers panel (seller listings): compare competing offers without going under contract. 4936 if(tx.type==='seller' && tx.category!=='commercial_lease'){ try{ body.appendChild(renderOffersSection(tx)); }catch(e){} } 4937 4938 // Contract Risk Review - prominent, right under the status. 4939 var riskSec = renderRiskReview(tx); 4940 if(riskSec) body.appendChild(riskSec); 4941 4942 // Commercial lease terms panel (only for lease transactions). 4943 if(tx.category === 'commercial_lease'){ body.appendChild(renderLeasePanel(tx)); } 4944 4945 // Document completeness checklist (deterministic - required docs per deal type).
4946 if(tx.status !== 'closed'){ body.appendChild(renderDocChecklist(tx)); } 4947 4948 // Per-deal activity timeline (shown for all deals, including closed - it's the history). 4949 body.appendChild(renderTxTimeline(tx)); 4950 4951 // Contact reassign row at top of detail body 4952 var cRow = document.createElement('div'); 4953 cRow.style.cssText = 'padding:10px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;background:var(--surface2);'; 4954 var cLbl = document.createElement('div'); 4955 cLbl.style.cssText = 'font-size:13px;color:var(--text3);white-space:nowrap;'; 4956 cLbl.textContent = 'Contact:'; 4957 var cSel = document.createElement('select'); 4958 cSel.className = 'fsel'; 4959 cSel.style.cssText = 'font-size:14px;flex:1;padding:4px 8px;'; 4960 var noOpt = document.createElement('option'); 4961 noOpt.value = ''; noOpt.textContent = '-- No contact assigned --'; 4962 cSel.appendChild(noOpt); 4963 C.forEach(function(ct){ 4964 var o = document.createElement('option'); 4965 o.value = ct.id; 4966 o.textContent = fn(ct) + (ctype1(ct) ? ' (' + ctype1(ct) + ')' : ''); 4967 cSel.appendChild(o); 4968 }); 4969 cSel.value = tx.contactId || ''; 4970 (function(txId){ cSel.addEventListener('change', function(){ 4971 var t = TX.find(function(x){ return String(x.id)===String(txId); }); 4972 if(!t) return; 4973 t.contactId = parseInt(cSel.value)||null; 4974 sv(); if(supaReady) dbSave('transactions',[t]);; 4975 // update subtitle 4976 var nc = gc(t.contactId); 4977 ge('tcDetSub').textContent = (t.price?t.price+' | ':'')+t.type.toUpperCase()+(t.closingDate?' | Close: '+fd(t.closingDate):''); 4978 }); })(id); 4979 cRow.appendChild(cLbl); cRow.appendChild(cSel); 4980 body.appendChild(cRow); 4981 4982 // Documents section 4983 var tDocSec = document.createElement('div'); 4984 tDocSec.style.cssText = 'padding:14px 20px;border-bottom:1px solid var(--border);'; 4985 var tDocHdr = document.createElement('div'); 4986 tDocHdr.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;'; 4987 var tDocTitle = document.createElement('div'); 4988 tDocTitle.style.cssText = 'font-size:18px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text3);'; 4989 tDocTitle.textContent = 'Documents'; 4990 var tDocUp = document.createElement('button'); 4991 tDocUp.style.cssText = 'background:none;border:none;color:var(--accent);cursor:pointer;font-size:18px;'; 4992 tDocUp.textContent = '+ Upload'; 4993 (function(txId, cId){ tDocUp.addEventListener('click', function(){ openDocUpload({transaction_id:txId, contact_id:cId||null}, function(){ openTCDetail(txId); }); }); })(id, tx.contactId); 4994 tDocHdr.appendChild(tDocTitle); tDocHdr.appendChild(tDocUp); 4995 tDocSec.appendChild(tDocHdr); 4996 var tDocs = docsForTransaction(id); 4997 if(!tDocs.length) tDocSec.appendChild(mkDiv('font-size:18px;color:var(--text3);padding:4px 0;','None yet.')); 4998 else tDocs.forEach(function(doc){ tDocSec.appendChild(buildDocRow(doc, function(){ openTCDetail(id); })); }); 4999 body.appendChild(tDocSec); 5000 5001 // Info section 5002 var infoSec = document.createElement('div'); 5003 infoSec.style.cssText = 'padding:14px 20px;border-bottom:1px solid var(--border);display:flex;flex-wrap:wrap;gap:10px;'; 5004 var fields = [ 5005 ['MLS #', tx.mlsNum||'-'], 5006 ['Lender', tx.lender||'-'], 5007 ['Title Co', tx.titleCo||'-'], 5008 ['Financing Type', financingTypeLabel(tx.financingType) || 'Conventional / bank loan'], 5009 ['Contract', tx.contractDate ? fd(tx.contractDate) : '-'], 5010 ['Close', tx.closingDate ? fd(tx.closingDate) : '-'], 5011 ['Earnest Due', tx.earnestDate ? fd(tx.earnestDate) : '-'], 5012 ['Due Diligence', tx.dueDiligDate ? fd(tx.dueDiligDate) : '-'] 5013 ]; 5014 // Bank financing + appraisal deadlines only matter when there's a bank loan. 5015 if(!noBankLoan(tx)){ 5016 fields.push(['Financing', tx.financingDate ? fd(tx.financingDate) : '-']); 5017 fields.push(['Appraisal', tx.appraisalDate ? fd(tx.appraisalDate) : '-']); 5018 } 5019 fields.forEach(function(f){ 5020 var d = document.createElement('div'); 5021 d.style.cssText = 'font-size:18px;min-width:120px;'; 5022 d.innerHTML = '<span style="color:var(--text3);">
5022' + f[0] + ':</span> <b style="color:var(--text);">' + f[1] + '</b>'; 5023 infoSec.appendChild(d); 5024 }); 5025 body.appendChild(infoSec); 5026 5027 // Seller-financing note terms (only when the seller is carrying the financing). 5028 if(tx.financingType === 'seller'){ 5029 var sf = txSF(tx); 5030 var sfSec = document.createElement('div'); 5031 sfSec.style.cssText = 'padding:12px 20px;border-bottom:1px solid var(--border);'; 5032 var sfHdr = document.createElement('div'); 5033 sfHdr.style.cssText = 'font-size:14px;color:var(--text3);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:8px;'; 5034 sfHdr.textContent = 'Seller Financing â Note Terms'; 5035 sfSec.appendChild(sfHdr); 5036 var sfGrid = document.createElement('div'); 5037 sfGrid.style.cssText = 'display:flex;flex-wrap:wrap;gap:10px;'; 5038 var sfRows = [ 5039 ['Amount financed', sf.amount], 5040 ['Interest rate', sf.rate ? (sf.rate + '%') : ''], 5041 ['Term', sf.termMonths ? (sf.termMonths + ' mo') : ''], 5042 ['Amortization', sf.amortMonths ? (sf.amortMonths + ' mo') : ''], 5043 ['Monthly payment', sf.payment], 5044 ['Down payment', sf.downPayment], 5045 ['Balloon date', sf.balloonDate ? fd(sf.balloonDate) : ''], 5046 ['Late fee', sf.lateFee], 5047 ['Due-on-sale', sf.dueOnSale] 5048 ]; 5049 sfRows.forEach(function(r){ 5050 var val = (r[1]==null || String(r[1]).trim()==='') ? '<span style="color:var(--danger);">not recorded</span>' : ('<b style="color:var(--text);">'+r[1]+'</b>'); 5051 var d = document.createElement('div'); 5052 d.style.cssText = 'font-size:18px;min-width:150px;'; 5053 d.innerHTML = '<span style="color:var(--text3);">
5053' + r[0] + ':</span> ' + val; 5054 sfGrid.appendChild(d); 5055 }); 5056 sfSec.appendChild(sfGrid); 5057 body.appendChild(sfSec); 5058 } 5059 5060 // Contract Facts (REPC) - a complete quick-reference of the terms the scan captures, grouped so 5061 // you can look up any of them without reopening the document. Shown for every residential 5062 // purchase; items not captured yet read "-" so you can see what's missing at a glance. 5063 if(tx.category !== 'commercial_lease' && (tx.type === 'buyer' || tx.type === 'seller')){ 5064 var rpGroups = repcReferenceGroups(txREPC(tx)); 5065 var rpCaptured = 0, rpTotal = 0; 5066 rpGroups.forEach(function(g){ g.rows.forEach(function(r){ rpTotal++; if(r[1] !== '') rpCaptured++; }); }); 5067 var rpStartOpen = rpCaptured > 0; 5068 var rpSec = document.createElement('div'); 5069 rpSec.style.cssText = 'padding:12px 20px;border-bottom:1px solid var(--border);'; 5070 var rpHdr = document.createElement('div'); 5071 rpHdr.style.cssText = 'font-size:14px;color:var(--text3);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:8px;
5071cursor:pointer;display:flex;align-items:center;gap:8px;'; 5072 var rpCaret = document.createElement('span'); rpCaret.style.cssText = 'font-size:12px;'; rpCaret.textContent = rpStartOpen ? 'â¾' : 'â¸'; 5073 var rpTitle = document.createElement('span'); rpTitle.textContent = 'Contract Facts (REPC)'; 5074 var rpCount = document.createElement('span'); rpCount.style.cssText = 'color:var(--text3);font-weight:400;text-transform:none;letter-spacing:0;'; rpCount.textContent = '· ' + rpCaptured + '/' + rpTotal + ' captured'; 5075 rpHdr.appendChild(rpCaret); rpHdr.appendChild(rpTitle); rpHdr.appendChild(rpCount); 5076 rpSec.appendChild(rpHdr); 5077 var rpBody = document.createElement('div'); rpBody.style.display = rpStartOpen ? '' : 'none'; 5078 rpGroups.forEach(function(g){ 5079 var gh = document.createElement('div'); gh.style.cssText = 'font-size:13px;color:var(--accent);font-weight:700;margin:8px 0 4px;'; gh.textContent = g.title; rpBody.appendChild(gh); 5080 var grid = document.createElement('div'); grid.style.cssText = 'display:flex;flex-wrap:wrap;gap:10px;'; 5081 g.rows.forEach(function(r){ 5082 var d = document.createElement('div'); d.style.cssText = 'font-size:18px;min-width:180px;max-width:100%;'; 5083 var lbl = document.createElement('span'); lbl.style.color = 'var(--text3)'; lbl.textContent = r[0] + ': '; 5084 var val = document.createElement('b'); 5085 if(r[1] === ''){ val.style.color = 'var(--text3)'; val.style.fontWeight = '400'; val.textContent = 'â'; } 5086 else { val.style.color = 'var(--text)'; val.textContent = r[1]; } 5087 d.appendChild(lbl); d.appendChild(val); grid.appendChild(d); 5088 }); 5089 rpBody.appendChild(grid); 5090 }); 5091 rpSec.appendChild(rpBody); 5092 rpHdr.addEventListener('click', function(){ var open = rpBody.style.display !== 'none'; rpBody.style.display = open ? 'none' : ''; rpCaret.textContent = open ? 'â¸' : 'â¾'; }); 5093 body.appendChild(rpSec); 5094 } 5095 5096 // Assign To for transaction 5097 var txAssignSec = document.createElement('div'); 5098 txAssignSec.style.cssText = 'padding:10px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;'; 5099 var txAssignLbl = document.createElement('div'); 5100 txAssignLbl.style.cssText = 'font-size:18px;color:var(--text3);flex-shrink:0;'; 5101 txAssignLbl.textContent = 'Assigned To:'; 5102 var txAssignSel = document.createElement('select'); 5103 txAssignSel.className = 'fsel assign-to-select'; 5104 txAssignSel.style.cssText = 'font-size:18px;padding:5px 8px;flex:1;'; 5105 txAssignSel.innerHTML = '<option value="">Unassigned</option>'; 5106 TM.forEach(function(m){ 5107 var o=document.createElement('option'); 5108 o.value=m.id; o.textContent=m.first+' '+m.last; 5109 txAssignSel.appendChild(o); 5110 }); 5111 txAssignSel.value = tx.assignedTo||''; 5112 (function(txId, sel){ txAssignSel.addEventListener('change', function(){ 5113 var t2=TX.find(function(x){return x.id===txId;}); 5114 if(!t2) return; 5115 t2.assignedTo = parseInt(sel.value)||null; 5116 saveTX(t2); 5117 }); })(id, txAssignSel); 5118 // Calendar link 5119 var txAssignedM = tx.assignedTo ? TM.find(function(x){return x.id===tx.assignedTo;}) : null; 5120 txAssignSec.appendChild(txAssignLbl); txAssignSec.appendChild(txAssignSel); 5121 if(txAssignedM && txAssignedM.calLink){ 5122 var txCalA = document.createElement('a'); 5123 txCalA.href = txAssignedM.calLink; txCalA.target='_blank'; 5124 txCalA.className='team-cal-btn'; txCalA.style.cssText='width:auto;padding:5px 10px;font-size:18px;flex-shrink:0;'; 5125 txCalA.innerHTML='<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> Calendar'; 5126 txAssignSec.appendChild(txCalA); 5127 } 5128 body.appendChild(txAssignSec); 5129 5130 // Progress 5131 var prog = tcProgress(tx); 5132 var progSec = document.createElement('div'); 5133 progSec.style.cssText = 'padding:12px 20px;border-bottom:1px solid var(--border);'; 5134 var progBar = document.createElement('div'); 5135 progBar.className = 'tc-progress-bar'; 5136 var progFill = document.createElement('div'); 5137 progFill.className = 'tc-progress-fill'; 5138 progFill.style.width = prog + '%'; 5139 progFill.style.background = prog >= 100 ? 'var(--lead)' : prog >= 50 ? 'var(--accent)' : 'var(--buyer)'; 5140 progBar.appendChild(progFill); 5141 var progLbl = document.createElement('div'); 5142 progLbl.style.cssText = 'font-size:18px;color:var(--text3);margin-top:4px;'; 5143 progLbl.textContent = prog + '% complete across all checklist items'; 5144 progSec.appendChild(progBar); progSec.appendChild(progLbl); 5145 body.appendChild(progSec); 5146 5147 // Checklist phases 5148 var template = TC_TEMPLATES[tx.type] || TC_TEMPLATES['buyer']; 5149 template.forEach(function(phase){ 5150 var doneInPhase = phase.steps.filter(function(s){ return tx.steps && tx.steps[s.key]; }).length; 5151 var phaseHdr = document.createElement('div'); 5152 phaseHdr.className = 'tc-phase-header'; 5153 phaseHdr.innerHTML = phase.phase + ' <span style="float:right;font-size:18px;color:var(--accent);">' + doneInPhase + '/' + phase.steps.length + '</span>'; 5154 body.appendChild(phaseHdr); 5155 5156 phase.steps.forEach(function(step){ 5157 var isDone = tx.steps && tx.steps[step.key]; 5158 var row = document.createElement('div'); 5159 row.className = 'tc-step'; 5160 row.style.padding = '9px 20px'; 5161 5162 var chk = document.createElement('div'); 5163 chk.className = 'tc-step-chk' + (isDone ? ' done' : ''); 5164 if(isDone){ 5165 var sv2 = document.createElementNS('http://www.w3.org/2000/svg','svg'); 5166 sv2.setAttribute('width','9'); sv2.setAttribute('height','9'); 5167 sv2.setAttribute('fill','none'); sv2.setAttribute('stroke','#0d0f14'); 5168 sv2.setAttribute('stroke-width','3'); sv2.setAttribute('viewBox','0 0 24 24'); 5169 var poly = document.createElementNS('http://www.w3.org/2000/svg','polyline'); 5170 poly.setAttribute('points','20,6 9,17 4,12');
5171 sv2.appendChild(poly); chk.appendChild(sv2); 5172 } 5173 (function(txId, stepKey, chkEl){ 5174 chk.addEventListener('click', function(){ 5175 var t = TX.find(function(x){ return x.id === txId; }); 5176 if(!t) return; 5177 if(!t.steps) t.steps = {}; 5178 t.steps[stepKey] = !t.steps[stepKey]; 5179 saveTX(tx); 5180 openTCDetail(txId); 5181 renderTC(); 5182 updateNbTC(); 5183 rd(); // refresh dashboard deadlines 5184 if(curPage==='deadlines') rdl(); // and the Deadlines tab, so a completed item clears now 5185 }); 5186 })(id, step.key, chk); 5187 5188 var info = document.createElement('div'); 5189 info.className = 'tc-step-info'; 5190 var lbl = document.createElement('div'); 5191 lbl.className = 'tc-step-label' + (isDone ? ' done' : ''); 5192 lbl.textContent = step.label; 5193 info.appendChild(lbl); 5194 5195 if(step.desc){ 5196 var dsc = document.createElement('div'); 5197 dsc.className = 'tc-step-desc'; 5198 dsc.textContent = step.desc; 5199 info.appendChild(dsc); 5200 } 5201 5202 // Show relevant date if this step has one 5203 if(step.hasDate && step.dateField && tx[step.dateField]){ 5204 var dateVal = tx[step.dateField]; 5205 if(dateVal){ 5206 var n = du(dateVal); 5207 var due = document.createElement('div'); 5208 due.className = 'tc-step-due' + (n < 0 ? ' overdue' : n <= 3 ? ' soon' : ''); 5209 due.textContent = n < 0 ? Math.abs(n)+'d overdue ('+fd(dateVal)+')' : n === 0 ? 'Due TODAY' : 'Due in '+n+'d ('+fd(dateVal)+')'; 5210 info.appendChild(due); 5211 } 5212 } 5213 5214 row.appendChild(chk); row.appendChild(info); 5215 body.appendChild(row); 5216 }); 5217 }); 5218 5219 // Notes 5220 if(tx.notes){ 5221 var notesSec = document.createElement('div'); 5222 notesSec.style.cssText = 'padding:14px 20px;border-top:1px solid var(--border);'; 5223 var notesLabel = document.createElement('div'); 5224 notesLabel.style.cssText = 'font-size:18px;color:var(--text3);text-transform:uppercase;letter-spacing:2px;margin-bottom:8px;'; 5225 notesLabel.textContent = 'Notes'; 5226 var notesText = document.createElement('div'); 5227 notesText.style.cssText = 'font-size:18px;color:var(--text2);line-height:1.7;white-space:pre-wrap;word-break:break-word;'; 5228 notesText.textContent = tx.notes; 5229 notesSec.appendChild(notesLabel); 5230 notesSec.appendChild(notesText); 5231 body.appendChild(notesSec); 5232 } 5233 5234 // GCal sync section in TC detail 5235 var gcalTxSec = document.createElement('div'); 5236 gcalTxSec.style.cssText = 'padding:12px 20px;border-top:1px solid var(--border);'; 5237 var gcalTxTitle = document.createElement('div'); 5238 gcalTxTitle.style.cssText = 'font-size:14px;color:var(--text3);margin-bottom:8px;'; 5239 gcalTxTitle.textContent = 'Sync to Google Calendar'; 5240 gcalTxSec.appendChild(gcalTxTitle); 5241 5242 var gcalBtnRow = document.createElement('div'); 5243 gcalBtnRow.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;'; 5244 5245 var txDateDefs = [ 5246 {key:'closingDate',label:'Closing Date',type:'closing'}, 5247 {key:'earnestDate',label:'Earnest Money Due',type:'deadline'}, 5248 {key:'dueDiligDate',label:'Due Diligence',type:'deadline'}, 5249 {key:'financingDate',label:'Financing Deadline',type:'deadline'}, 5250 {key:'appraisalDate',label:'Appraisal Deadline',type:'deadline'} 5251 ]; 5252 5253 txDateDefs.forEach(function(td){ 5254 if(!tx[td.key]) return; 5255 var btn = document.createElement('button'); 5256 btn.className = 'btn btn-g'; 5257 btn.style.cssText += 'font-size:14px;padding:5px 10px;'; 5258 btn.innerHTML = '+ ' + td.label; 5259 btn.title = 'Add ' + td.label + ' to Google Calendar'; 5260 var c7=gc(tx.contactId); 5261 (function(dateKey, lbl, typ){ 5262 btn.addEventListener('click', function(){ 5263 syncToGCal({ 5264 title:lbl+': '+(tx.address||(c7?fn(c7):'Unknown')), 5265 date:tx[dateKey],time:'09:00',type:typ, 5266 clientName:c7?fn(c7):'', 5267 address:tx.address||'', 5268 notes:'Transaction deadline from Properly CRM' 5269 }, tx.assignedTo); 5270 }); 5271 })(td.key, td.label, td.type); 5272 gcalBtnRow.appendChild(btn); 5273 }); 5274 5275 gcalTxSec.appendChild(gcalBtnRow); 5276 body.appendChild(gcalTxSec); 5277 5278 ge('tcDetOv').classList.add('open'); 5279 ge('tcAiResponse').style.display = 'none'; 5280 ge('tcAiInput').value = ''; 5281} 5282 5283// AI Status Query 5284async function askTCQuestion(){ 5285 var txId = curTx; 5286 var tx = TX.find(function(t){ return t.id === txId; }); 5287 if(!tx) return; 5288 var question = ge('tcAiInput').value.trim(); 5289 if(!question) return; 5290 5291 var btn = ge('btnTCAsk'); 5292 btn.textContent = '...'; 5293 btn.disabled = true; 5294 ge('tcAiResponse').style.display = 'block'; 5295 ge('tcAiResponse').textContent = 'Thinking...'; 5296 5297 var template = TC_TEMPLATES[tx.type] || TC_TEMPLATES['buyer']; 5298 var doneItems = [], pendingItems = [], urgentItems = []; 5299 template.forEach(function(phase){ 5300 phase.steps.forEach(function(step){ 5301 if(tx.steps && tx.steps[step.key]){ 5302 doneItems.push(phase.phase + ': ' + step.label); 5303 } else { 5304 pendingItems.push(phase.phase + ': ' + step.label); 5305 if(step.hasDate && step.dateField){ 5306 if(tx[step.dateField]){ 5307 var n = du(tx[step.dateField]); 5308 if(n <= 3) urgentItems.push(step.label + ' (due in ' + n + ' days)'); 5309 } 5310 } 5311 } 5312 }); 5313 }); 5314 5315 var c = gc(tx.contactId); 5316 var NL = '\n'; 5317 var context = 'You are a transaction coordinator AI for Palacios Baker Real Estate in Utah.' + NL 5318 + 'Transaction: ' + (tx.address||'Unknown address') + NL 5319 + 'Type: ' + tx.type + ' transaction' + NL 5320 + 'Client: ' + (c ? fn(c) : 'Unknown') + NL 5321 + 'Sale Price: ' + (tx.price||'Not set') + NL 5322 + 'Contract Date: ' + (tx.contractDate||'Not set') + NL 5323 + 'Closing Date: ' + (tx.closingDate||'Not set') + NL 5324 + 'Earnest Money Due: ' + (tx.earnestDate||'Not set') + NL 5325 + 'Due Diligence Deadline: ' + (tx.dueDiligDate||'Not set') + NL 5326 + 'Financing Deadline: ' + (tx.financingDate||'Not set') + NL 5327 + 'Appraisal Deadline: ' + (tx.appraisalDate||'Not set') + NL 5328 + 'Lender: ' + (tx.lender||'Not set') + NL 5329 + 'Title Company: ' + (tx.titleCo||'Not set') + NL
5330 + 'Progress: ' + tcProgress(tx) + '% complete' + NL + NL 5331 + 'COMPLETED STEPS (' + doneItems.length + '):' + NL + doneItems.join(NL) + NL + NL 5332 + 'PENDING STEPS (' + pendingItems.length + '):' + NL + pendingItems.join(NL) + NL + NL 5333 + (urgentItems.length ? 'URGENT / UPCOMING DEADLINES:' + NL + urgentItems.join(NL) + NL + NL : '') 5334 + 'Agent question: ' + question + NL + NL 5335 + 'Give a clear, practical, concise answer. Be direct. Use bullet points if listing multiple items.'; 5336 5337 try{ 5338 var resp = await fetch('/api/claude', { 5339 method: 'POST', 5340 headers: await apiHeaders(), 5341 body: JSON.stringify({ 5342 model: 'claude-sonnet-4-20250514', 5343 max_tokens: 1500, 5344 messages: [{role:'user', content: context}] 5345 }) 5346 }); 5347 var data = await resp.json(); 5348 var answer = data.content && data.content[0] ? data.content[0].text : 'Unable to get a response.'; 5349 ge('tcAiResponse').textContent = answer; 5350 }catch(e){ 5351 ge('tcAiResponse').textContent = 'Error connecting to AI. Check your connection and try again.'; 5352 } 5353 btn.textContent = 'Ask'; 5354 btn.disabled = false; 5355} 5356 5357function openNewTxModal(){ 5358 ge('tcModalTitle').textContent = 'New Transaction'; 5359 ge('btnSaveTransaction').setAttribute('data-edit-id',''); 5360 ge('tcType').value = 'buyer'; 5361 var txCP = ge('tcContactPick'); txCP.innerHTML = ''; 5362 buildContactPicker(txCP, 'tcContact', 'Search contact by name, email, phone...'); 5363 ['tcAddress','tcPrice','tcMLS','tcLender','tcTitle','tcNotes','tcListCommPct','tcBuyerCommPct','tcListCommAmt','tcBuyerCommAmt'].forEach(function(x){ if(ge(x)) ge(x).value=''; }); 5364 ['tcContractDate','tcClosingDate','tcEarnestDate','tcDueDiligDate','tcFinancingDate','tcAppraisalDate'].forEach(function(x){ ge(x).value=''; }); 5365 if(ge('tcFinancingType')) ge('tcFinancingType').value=''; 5366 cm('tcModal'); 5367 ge('tcModal').classList.add('open'); 5368} 5369 5370function saveTransaction(){ 5371 var pickedContactId = parseInt(ge('tcContact').value); 5372 if(!pickedContactId){ alert('Please select a contact for this transaction.'); return; } 5373 var editId = ge('btnSaveTransaction').getAttribute('data-edit-id'); 5374 var tx; 5375 if(editId){ 5376 tx = TX.find(function(t){ return t.id == editId; }); 5377 if(!tx) return; 5378 } else { 5379 tx = {id: Date.now(), steps: {}, status: 'active'}; 5380 TX.push(tx); 5381 } 5382 tx.type = ge('tcType').value; 5383 tx.contactId = pickedContactId; 5384 tx.address = ge('tcAddress').value.trim(); 5385 tx.price = ge('tcPrice').value.trim(); 5386 tx.mlsNum = ge('tcMLS').value.trim(); 5387 tx.lender = ge('tcLender').value.trim(); 5388 tx.titleCo = ge('tcTitle').value.trim(); 5389 tx.notes = ge('tcNotes').value.trim(); 5390 if(ge('tcFinancingType')) tx.financingType = ge('tcFinancingType').value; 5391 tx.contractDate = ge('tcContractDate').value; 5392 tx.closingDate = ge('tcClosingDate').value; 5393 tx.earnestDate = ge('tcEarnestDate').value; 5394 tx.dueDiligDate = ge('tcDueDiligDate').value; 5395 tx.financingDate = ge('tcFinancingDate').value; 5396 tx.appraisalDate = ge('tcAppraisalDate').value; 5397 tx.listCommissionPct = normPct(ge('tcListCommPct') ? ge('tcListCommPct').value : ''); 5398 tx.buyerCommissionPct = normPct(ge('tcBuyerCommPct') ? ge('tcBuyerCommPct').value : ''); 5399 var _lAmt = ge('tcListCommAmt') ? ge('tcListCommAmt').value.trim() : ''; 5400 var _bAmt = ge('tcBuyerCommAmt') ? ge('tcBuyerCommAmt').value.trim() : ''; 5401 tx.listCommissionAmt = _lAmt ? parsePrice(_lAmt) : null; // manual override (used when % blank) 5402 tx.buyerCommissionAmt = _bAmt ? parsePrice(_bAmt) : null; // manual override (used when % blank) 5403 recomputeCommission(tx); // % present -> amount derived; % blank -> keep manual flat fee 5404 5405 // Auto-create deadlines in the main deadline tracker 5406 var dateMap = [ 5407 {type:'Earnest Money Due', key:'earnestDate'}, 5408 {type:'Due Diligence Deadline', key:'dueDiligDate'}, 5409 {type:'Financing Deadline', key:'financingDate'}, 5410 {type:'Appraisal Deadline', key:'appraisalDate'}, 5411 {type:'Closing Date', key:'closingDate'} 5412 ]; 5413 dateMap.forEach(function(dm){ 5414 if(tx[dm.key]){ 5415 var exists = D.find(function(d){ return d.contactId===tx.contactId && d.type===dm.type; }); 5416 if(!exists){ 5417 var nd = {id: Date.now()+Math.random(), contactId:tx.contactId, type:dm.type, date:tx[dm.key]}; 5418 D.push(nd); 5419 saveDL(nd); 5420 } 5421 } 5422 }); 5423 5424 saveTX(tx); if(tx.contactId) logActivity(tx.contactId,'Updated transaction'); 5425 // A contract date is the signal that they're actually under contract - that's what puts 5426 // them on the Pipeline board. No contract date yet (e.g. prepping a listing) = no move. 5427 // Only ever moves forward: never pulls a Closed deal back. 5428 if(tx.contactId && tx.contractDate && tx.status !== 'closed'){ 5429 var _sc = gc(tx.contactId); 5430 if(_sc && _sc.stage !== 'Under Contract' && _sc.stage !== 'Closed') ss(tx.contactId, 'Under Contract'); 5431 } 5432 cm('tcModal'); 5433 renderTC(); 5434 updateNbTC(); 5435 rd(); 5436 if(curTx && curTx === tx.id) openTCDetail(tx.id); 5437} 5438 5439// Add TC to briefing 5440function getTCBriefingItems(){ 5441 var items = []; 5442 var today = tod(); 5443 TX.filter(function(t){ return t.status !== 'closed'; }
5443).forEach(function(tx){ 5444 var c = gc(tx.contactId); 5445 var name = c ? fn(c) : 'Unknown'; 5446 // Check urgent deadlines 5447 var dateChecks = [ 5448 {label:'Earnest Money Due', key:'earnestDate'}, 5449 {label:'Due Diligence', key:'dueDiligDate'}, 5450 {label:'Financing Deadline', key:'financingDate'}, 5451 {label:'Appraisal Deadline', key:'appraisalDate'}, 5452 {label:'Closing Date', key:'closingDate'} 5453 ]; 5454 dateChecks.forEach(function(dc){ 5455 if(tx[dc.key]){ 5456 var n = du(tx[dc.key]); 5457 if(n <= 3 && n >= -1){ 5458 items.push({name: name, address: tx.address, label: dc.label, days: n, urgent: n <= 0}); 5459 } 5460 } 5461 }); 5462 // Next pending step 5463 var next = tcNextSteps(tx); 5464 if(next.length) items.push({name: name, address: tx.address, nextStep: next[0].label, type:'nextstep'}); 5465 }); 5466 return items; 5467} 5468 5469// -- BUSINESS CARD & QR SCANNER --- 5470var cardScannerState = { 5471 stream: null, 5472 qrScanning: false, 5473 qrInterval: null 5474}; 5475 5476function initCardScanner(){ 5477 // Tab switching 5478} 5479 5480// PHOTO / UPLOAD CARD SCAN 5481function startCardScan(file){ 5482 if(!file) return; 5483 if(file.size > 20 * 1024 * 1024){ alert('File too large. Please use an image under 20MB.'); return; } 5484 5485 ge('cardDrop').style.display = 'none'; 5486 ge('cardResult').style.display = 'none'; 5487 ge('cardProcessing').style.display = 'block'; 5488 ge('cardProcessingMsg').textContent = 'Reading business card...'; 5489 5490 // Compress image before sending to reduce payload size 5491 var img = new Image(); 5492 var objectUrl = URL.createObjectURL(file); 5493 img.onload = function(){ 5494 var canvas = document.createElement('canvas'); 5495 var maxDim = 1200; // max 1200px on longest side 5496 var w = img.width, h = img.height; 5497 if(w > maxDim || h > maxDim){ 5498 if(w > h){ h = Math.round(h * maxDim / w); w = maxDim; } 5499 else { w = Math.round(w * maxDim / h); h = maxDim; } 5500 } 5501 canvas.width = w; canvas.height = h; 5502 var ctx = canvas.getContext('2d'); 5503 ctx.drawImage(img, 0, 0, w, h); 5504 var compressed = canvas.toDataURL('image/jpeg', 0.85); 5505 var base64 = compressed.split(',')[1]; 5506 URL.revokeObjectURL(objectUrl); 5507 analyzeBusinessCard(base64, 'image/jpeg'); 5508 }; 5509 img.onerror = function(){ 5510 // Fall back to direct read if compression fails 5511 var reader = new FileReader(); 5512 reader.onload = function(e){ 5513 var base64 = e.target.result.split(',')[1]; 5514 analyzeBusinessCard(base64, file.type || 'image/jpeg'); 5515 }; 5516 reader.readAsDataURL(file); 5517 }; 5518 img.src = objectUrl; 5519} 5520 5521async function analyzeBusinessCard(base64, mediaType){ 5522 var prompt = [ 5523 'This is a business card image. Extract ALL contact information you can find.', 5524 'Return ONLY a valid JSON object with these exact fields (use empty string if not found):', 5525 '{', 5526 ' "firstName": "first name",', 5527 ' "lastName": "last name",', 5528 ' "fullName": "full name as shown",', 5529 ' "title": "job title or position",', 5530 ' "company": "company or business name",', 5531 ' "phone": "primary phone number",', 5532 ' "phone2": "secondary phone number if present",', 5533 ' "mobile": "mobile/cell number if labeled",', 5534 ' "email": "primary email address",', 5535 ' "email2": "secondary email if present",', 5536 ' "website": "website URL",', 5537 ' "address": "full address if present",', 5538 ' "linkedin": "linkedin URL or username",', 5539 ' "instagram": "instagram handle or URL",', 5540 ' "facebook": "facebook URL or name",', 5541 ' "twitter": "twitter/X handle",', 5542 ' "youtube": "youtube channel if present",', 5543 ' "tiktok": "tiktok handle if present",', 5544 ' "whatsapp": "whatsapp number if labeled",', 5545 ' "qrData": "any text visible in a QR code if you can read it",', 5546 ' "notes": "any other relevant information on the card",', 5547 ' "cardType": "business|personal|realtor|other"', 5548 '}', 5549 'Be thorough. Extract every piece of contact information visible on the card.', 5550 'For phone numbers include the country code if shown.', 5551 'For social media just extract the handle/username or full URL as shown on the card.' 5552 ].join('\n'); 5553 5554 try{ 5555 var resp = await fetch('/api/claude', { 5556 method:'POST', 5557 headers: await apiHeaders(), 5558 body: JSON.stringify({ 5559 model:'claude-sonnet-4-20250514', 5560 max_tokens:1500, 5561 messages:[{ 5562 role:'user', 5563 content:[ 5564 {type:'image', source:{type:'base64', media_type:mediaType, data:base64}}, 5565 {type:'text', text:prompt} 5566 ] 5567 }] 5568 }) 5569 }); 5570 var data = await resp.json(); 5571 if(!data.content || !data.content[0]){ showCardError('No response from AI.'); return; } 5572 var text = data.content[0].text; 5573 var renderOk = false;
5574 try{ 5575 var clean = text.replace(/```json/g,'').replace(/```/g,'').trim(); 5576 var fb = clean.indexOf('{'); var lb = clean.lastIndexOf('}'); 5577 if(fb !== -1 && lb > fb) clean = clean.substring(fb, lb+1); 5578 var result = JSON.parse(clean); 5579 showCardResult(result); 5580 }catch(e){ 5581 // Try to show whatever came back 5582 showCardError('Could not parse card data. Try a clearer photo.'); 5583 } 5584 }catch(e){ showCardError('Scan error: ' + e.message); } 5585} 5586 5587function showCardResult(r){ 5588 ge('cardProcessing').style.display = 'none'; 5589 var el = ge('cardResult'); 5590 el.style.display = 'block'; 5591 el.innerHTML = ''; 5592 5593 var card = document.createElement('div'); 5594 card.className = 'card-result'; 5595 5596 // Header with avatar 5597 var hdr = document.createElement('div'); 5598 hdr.className = 'card-result-header'; 5599 var av = document.createElement('div'); 5600 av.className = 'card-result-avatar'; 5601 av.textContent = ((r.firstName||r.fullName||'?').charAt(0) + (r.lastName||'').charAt(0)).toUpperCase() || '?'; 5602 var nameDiv = document.createElement('div'); 5603 var nameEl = document.createElement('div'); 5604 nameEl.className = 'card-result-name'; 5605 nameEl.textContent = r.fullName || ((r.firstName||'') + ' ' + (r.lastName||'')).trim() || 'Unknown'; 5606 var compEl = document.createElement('div'); 5607 compEl.className = 'card-result-company'; 5608 compEl.textContent = (r.title ? r.title + (r.company ? ' at ' + r.company : '') : r.company) || ''; 5609 nameDiv.appendChild(nameEl); 5610 if(compEl.textContent) nameDiv.appendChild(compEl); 5611 hdr.appendChild(av); hdr.appendChild(nameDiv); 5612 card.appendChild(hdr); 5613 5614 // Editable fields 5615 var fields = [ 5616 {label:'First Name', key:'firstName', icon:'👤', color:'#4C8EC9', id:'cf_first'}, 5617 {label:'Last Name', key:'lastName', icon:'👤', color:'#4C8EC9', id:'cf_last'}, 5618 {label:'Job Title', key:'title', icon:'👔', color:'#C9A84C', id:'cf_title'}, 5619 {label:'Company', key:'company', icon:'🏢', color:'#C9A84C', id:'cf_company'}, 5620 {label:'Phone', key:'phone', icon:'📞', color:'#6BC97A', id:'cf_phone'}, 5621 {label:'Mobile', key:'mobile', icon:'📱', color:'#6BC97A', id:'cf_mobile'}, 5622 {label:'Phone 2', key:'phone2', icon:'📞', color:'#6BC97A', id:'cf_phone2'}, 5623 {label:'Email', key:'email', icon:'✉', color:'#C94C8E', id:'cf_email'}, 5624 {label:'Email 2', key:'email2', icon:'✉', color:'#C94C8E', id:'cf_email2'}, 5625 {label:'Website', key:'website', icon:'🌐', color:'#4C8EC9', id:'cf_website'}, 5626 {label:'Address', key:'address', icon:'📍', color:'#C96B4C', id:'cf_address'}, 5627 {label:'LinkedIn', key:'linkedin', icon:'in', color:'#0077b5', id:'cf_linkedin'}, 5628 {label:'Instagram', key:'instagram', icon:'📷', color:'#E1306C', id:'cf_instagram'}, 5629 {label:'Facebook', key:'facebook', icon:'f', color:'#1877F2', id:'cf_facebook'}, 5630 {label:'Twitter/X', key:'twitter', icon:'X', color:'#000000', id:'cf_twitter'}, 5631 {label:'TikTok', key:'tiktok', icon:'♫', color:'#ff0050', id:'cf_tiktok'}, 5632 {label:'WhatsApp', key:'whatsapp', icon:'💬', color:'#25D366', id:'cf_whatsapp'}, 5633 {label:'Notes', key:'notes', icon:'📋', color:'#8A8A7A', id:'cf_notes'} 5634 ]; 5635 5636 fields.forEach(function(f){ 5637 if(!r[f.key]) return; // Skip empty fields 5638 var row = document.createElement('div'); 5639 row.className = 'card-field'; 5640 var icon = document.createElement('div'); 5641 icon.className = 'card-field-icon'; 5642 icon.style.background = f.color + '22'; 5643 icon.style.color = f.color; 5644 icon.innerHTML = f.icon; 5645 var info = document.createElement('div'); 5646 info.style.flex = '1'; 5647 var lbl = document.createElement('div'); 5648 lbl.className = 'card-field-label'; 5649 lbl.textContent = f.label; 5650 var inp = document.createElement('input'); 5651 inp.className = 'card-field-input'; 5652 inp.id = f.id; 5653 inp.value = r[f.key] || ''; 5654 inp.placeholder = f.label; 5655 info.appendChild(lbl); info.appendChild(inp); 5656 row.appendChild(icon); row.appendChild(info); 5657 card.appendChild(row); 5658 }); 5659 5660 // Contact type selector 5661 var typeRow = document.createElement('div');
5662 typeRow.style.cssText = 'margin-top:14px;padding-top:14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:12px;'; 5663 var typeLbl = document.createElement('label'); 5664 typeLbl.style.cssText = 'font-size:14px;color:var(--text3);flex-shrink:0;'; 5665 typeLbl.textContent = 'Contact Type:'; 5666 var typeSel = document.createElement('select'); 5667 typeSel.className = 'fsel'; 5668 typeSel.id = 'cf_type'; 5669 typeSel.style.flex = '1'; 5670 ['lead','buyer','seller'].forEach(function(t){ 5671 var o = document.createElement('option'); 5672 o.value = t; o.textContent = t.charAt(0).toUpperCase()+t.slice(1); 5673 typeSel.appendChild(o); 5674 }); 5675 typeRow.appendChild(typeLbl); typeRow.appendChild(typeSel); 5676 card.appendChild(typeRow); 5677 5678 // Action buttons 5679 var actions = document.createElement('div'); 5680 actions.style.cssText = 'display:flex;gap:10px;flex-wrap:wrap;margin-top:16px;padding-top:14px;border-top:1px solid var(--border);'; 5681 5682 var importBtn = document.createElement('button'); 5683 importBtn.className = 'btn btn-p'; 5684 importBtn.style.fontSize = '15px'; 5685 importBtn.textContent = 'Save as Contact'; 5686 importBtn.addEventListener('click', function(){ importCardContact(r); }); 5687 5688 var rescanBtn = document.createElement('button'); 5689 rescanBtn.className = 'btn btn-g'; 5690 rescanBtn.textContent = 'Scan Another'; 5691 rescanBtn.addEventListener('click', function(){ 5692 el.style.display = 'none'; 5693 ge('cardDrop').style.display = 'block'; 5694 ge('cardFileInput').value = ''; 5695 ge('cardCameraInput').value = ''; 5696 }); 5697 5698 actions.appendChild(importBtn); actions.appendChild(rescanBtn); 5699 card.appendChild(actions); 5700 el.appendChild(card); 5701} 5702 5703function importCardContact(r){ 5704 var firstName = (ge('cf_first') && ge('cf_first').value) || r.firstName || (r.fullName||'').split(' ')[0] || 'Unknown'; 5705 var lastName = (ge('cf_last') && ge('cf_last').value) || r.lastName || (r.fullName||'').split(' ').slice(1).join(' ') || ''; 5706 var type = ge('cf_type') ? ge('cf_type').value : 'lead'; 5707 5708 // Build notes with all extra fields 5709 var extraLines = []; 5710 if(ge('cf_title') && ge('cf_title').value) extraLines.push('Title: ' + ge('cf_title').value); 5711 if(ge('cf_company') && ge('cf_company').value) extraLines.push('Company: ' + ge('cf_company').value); 5712 if(ge('cf_mobile') && ge('cf_mobile').value) extraLines.push('Mobile: ' + ge('cf_mobile').value); 5713 if(ge('cf_phone2') && ge('cf_phone2').value) extraLines.push('Phone 2: ' + ge('cf_phone2').value); 5714 if(ge('cf_email2') && ge('cf_email2').value) extraLines.push('Email 2: ' + ge('cf_email2').value); 5715 if(ge('cf_website') && ge('cf_website').value) extraLines.push('Website: ' + ge('cf_website').value); 5716 if(ge('cf_linkedin') && ge('cf_linkedin').value) extraLines.push('LinkedIn: ' + ge('cf_linkedin').value); 5717 if(ge('cf_instagram') && ge('cf_instagram').value) extraLines.push('Instagram: ' + ge('cf_instagram').value); 5718 if(ge('cf_facebook') && ge('cf_facebook').value) extraLines.push('Facebook: ' + ge('cf_facebook').value); 5719 if(ge('cf_twitter') && ge('cf_twitter').value) extraLines.push('Twitter: ' + ge('cf_twitter').value); 5720 if(ge('cf_tiktok') && ge('cf_tiktok').value) extraLines.push('TikTok: ' + ge('cf_tiktok').value); 5721 if(ge('cf_whatsapp') && ge('cf_whatsapp').value) extraLines.push('WhatsApp: ' + ge('cf_whatsapp').value); 5722 if(ge('cf_notes') && ge('cf_notes').value) extraLines.push(ge('cf_notes').value); 5723 extraLines.push('Imported from business card scan.'); 5724 5725 var nc = { 5726 id: Date.now(), 5727 first: firstName, 5728 last: lastName, 5729 type: type, 5730 phone: (ge('cf_phone') && ge('cf_phone').value) || r.phone || '', 5731 email: (ge('cf_email') && ge('cf_email').value) || r.email || '', 5732 property: '', 5733 stage: 'New Lead', 5734 price: '', 5735 notes: extraLines.join('\n'), 5736 added: new Date().toISOString() 5737 }; 5738 5739 // Check for duplicate by email or phone 5740 var dup = C.find(function(c){ 5741 return (nc.email && c.email && c.email.toLowerCase() === nc.email.toLowerCase()) || 5742 (nc.phone && c.phone && c.phone.replace(/\D/g,'') === nc.phone.replace(/\D/g,'')); 5743 }); 5744 5745 if(dup){ 5746 if(!confirm('A contact with this email or phone already exists (' + dup.first + ' ' + dup.last + ').
5746Create anyway?')) return; 5747 } 5748 5749 C.push(nc); 5750 saveContact(nc); 5751 alert('Contact saved: ' + firstName + ' ' + lastName + '!'); 5752 sp('contacts'); 5753 rc(); 5754} 5755 5756function showCardError(msg){ 5757 ge('cardProcessing').style.display = 'none'; 5758 ge('cardDrop').style.display = 'block'; 5759 alert(msg); 5760} 5761 5762// QR CODE SCANNER 5763function startQRScanner(){ 5764 ge('qrStartWrap').style.display = 'none'; 5765 ge('qrVideoWrap').style.display = 'block'; 5766 ge('qrManualInput').style.display = 'block'; 5767 5768 navigator.mediaDevices.getUserMedia({ 5769 video: { facingMode: 'environment', width:{ideal:1280}, height:{ideal:720} } 5770 }).then(function(stream){ 5771 cardScannerState.stream = stream; 5772 var video = ge('qrVideo'); 5773 video.srcObject = stream; 5774 video.play(); 5775 cardScannerState.qrScanning = true; 5776 scanQRFrame(); 5777 }).catch(function(err){ 5778 ge('qrVideoWrap').style.display = 'none'; 5779 ge('qrStartWrap').style.display = 'block'; 5780 alert('Camera access denied or not available. Use the manual text input below instead.'); 5781 ge('qrManualInput').style.display = 'block'; 5782 }); 5783} 5784 5785function stopQRScanner(){ 5786 cardScannerState.qrScanning = false; 5787 if(cardScannerState.qrInterval) clearInterval(cardScannerState.qrInterval); 5788 if(cardScannerState.stream){ 5789 cardScannerState.stream.getTracks().forEach(function(t){ t.stop(); }); 5790 cardScannerState.stream = null; 5791 } 5792 ge('qrVideoWrap').style.display = 'none'; 5793 ge('qrStartWrap').style.display = 'block'; 5794} 5795 5796function scanQRFrame(){ 5797 if(!cardScannerState.qrScanning) return; 5798 var video = ge('qrVideo'); 5799 var canvas = ge('qrCanvas'); 5800 if(!video || !canvas) return; 5801 5802 if(video.readyState === video.HAVE_ENOUGH_DATA){ 5803 canvas.width = video.videoWidth; 5804 canvas.height = video.videoHeight; 5805 var ctx = canvas.getContext('2d'); 5806 ctx.drawImage(video, 0, 0, canvas.width, canvas.height); 5807 5808 // Use BarcodeDetector API if available (modern browsers) 5809 if('BarcodeDetector' in window){ 5810 var detector = new BarcodeDetector({formats:['qr_code']}); 5811 detector.detect(canvas).then(function(barcodes){ 5812 if(barcodes.length > 0){ 5813 cardScannerState.qrScanning = false; 5814 stopQRScanner();
5815 parseQRData(barcodes[0].rawValue); 5816 } else { 5817 setTimeout(scanQRFrame, 300); 5818 } 5819 }).catch(function(){ setTimeout(scanQRFrame, 300); }); 5820 } else { 5821 // BarcodeDetector not available - prompt for manual 5822 stopQRScanner(); 5823 ge('qrManualInput').style.display = 'block'; 5824 alert('QR scanning not supported in this browser. Please use the manual text input or try Chrome on Android.'); 5825 } 5826 } else { 5827 setTimeout(scanQRFrame, 300); 5828 } 5829} 5830 5831function parseQRData(text){ 5832 if(!text) return; 5833 ge('cardResult').style.display = 'none'; 5834 ge('cardProcessing').style.display = 'block'; 5835 ge('cardProcessingMsg').textContent = 'Parsing QR data...'; 5836 5837 // Check if it's a vCard 5838 if(text.toUpperCase().indexOf('BEGIN:VCARD') !== -1){ 5839 var result = parseVCard(text); 5840 showCardResult(result); 5841 return; 5842 } 5843 5844 // Check if it's a URL - fetch and parse 5845 if(text.startsWith('http://') || text.startsWith('https://')){ 5846 // Send to AI to parse the URL/page info 5847 analyzeQRText('This QR code contains this URL: ' + text + '. Extract any contact information visible or implied by this URL (LinkedIn profile, website contact page, etc). The URL itself might be a social media profile or contact page.', text); 5848 return; 5849 } 5850 5851 // Plain text - send to AI 5852 analyzeQRText(text, text); 5853} 5854 5855function parseVCard(vcardText){ 5856 // Parse standard vCard format 5857 var r = {firstName:'',lastName:'',fullName:'',title:'',company:'',phone:'',mobile:'',phone2:'',email:'',email2:'',website:'',address:'',linkedin:'',instagram:'',twitter:'',notes:'',cardType:'business'}; 5858 var lines = vcardText.replace(/\r\n/g,'\n').replace(/\r/g,'\n').split('\n'); 5859 var phoneCount = 0; 5860 var emailCount = 0; 5861 5862 lines.forEach(function(line){ 5863 var parts = line.split(':'); 5864 if(parts.length < 2) return; 5865 var key = parts[0].toUpperCase(); 5866 var val = parts.slice(1).join(':').trim(); 5867 5868 if(key === 'FN') r.fullName = val; 5869 else if(key === 'N'){ 5870 var np = val.split(';'); 5871 r.lastName = (np[0]||'').trim(); 5872 r.firstName = (np[1]||'').trim(); 5873 } 5874 else if(key === 'ORG') r.company = val.replace(';','').trim(); 5875 else if(key === 'TITLE') r.title = val; 5876 else if(key.indexOf('TEL') !== -1){ 5877 if(key.indexOf('CELL') !== -1 || key.indexOf('MOBILE') !== -1) r.mobile = val; 5878 else if(!r.phone) r.phone = val; 5879 else if(!r.phone2) r.phone2 = val; 5880 } 5881 else if(key.indexOf('EMAIL') !== -1){ 5882 if(!r.email) r.email = val; 5883 else if(!r.email2) r.email2 = val; 5884 } 5885 else if(key.indexOf('URL') !== -1){ 5886 if(val.indexOf('linkedin') !== -1) r.linkedin = val; 5887 else if(val.indexOf('instagram') !== -1) r.instagram = val; 5888 else if(val.indexOf('twitter') !== -1 || val.indexOf('x.com') !== -1) r.twitter = val; 5889 else if(val.indexOf('facebook') !== -1) r.facebook = val; 5890 else r.website = r.website || val; 5891 } 5892 else if(key.indexOf('ADR') !== -1){ 5893 r.address = val.replace(/;/g,' ').trim(); 5894 } 5895 else if(key === 'NOTE') r.notes = val; 5896 }); 5897 5898 if(!r.firstName && r.fullName){ 5899 var np2 = r.fullName.trim().split(' '); 5900 r.firstName = np2[0] || ''; 5901 r.lastName = np2.slice(1).join(' ') || ''; 5902 } 5903 5904 showCardResult(r); 5905 return r; 5906} 5907 5908async function analyzeQRText(text, rawForFallback){ 5909 var prompt = [ 5910 'Extract contact information from this text/URL from a QR code.', 5911 'Return ONLY a valid JSON object with these fields (empty string if not found):', 5912 '{"firstName":"","lastName":"","fullName":"","title":"","company":"","phone":"","mobile":"","phone2":"","email":"","email2":"","website":"","address":"","linkedin":"","instagram":"","facebook":"","twitter":"","tiktok":"","whatsapp":"","notes":"","cardType":"business"}', 5913 'Text/URL to parse:', 5914 text 5915 ].join('\n'); 5916 5917 try{ 5918 var resp = await fetch('/api/claude', { 5919 method:'POST', 5920 headers: await apiHeaders(), 5921 body: JSON.stringify({ 5922 model:'claude-sonnet-4-20250514', 5923 max_tokens:800, 5924 messages:[{role:'user', content:prompt}] 5925 }) 5926 }); 5927 var data = await resp.json(); 5928 if(data && data.error){ showCardError('AI error: ' + String(data.error.message || JSON.stringify(data.error)).slice(0,200)); return; } 5929 var responseText = (data.content && data.content[0] && data.content[0].text) ? data.content[0].text : ''; 5930 if(!responseText){ showCardError('The AI returned an empty response for this QR code.' + (rawForFallback ? ' Saved the raw value instead.' : '')); if(rawForFallback) showCardResult(_qrFallback(rawForFallback)); return; } 5931 try{ 5932 var clean = responseText.replace(/```json/g,'').replace(/```/g,'').trim(); 5933 var fb = clean.indexOf('{'); var lb = clean.lastIndexOf('}'); 5934 if(fb !== -1 && lb > fb) clean = clean.substring(fb, lb+1); 5935 var result = JSON.parse(clean); 5936 showCardResult(result); 5937 }catch(e){ 5938 // AI replied but not as JSON - fall back to a raw contact so nothing is lost. 5939 if(rawForFallback) showCardResult(_qrFallback(rawForFallback)); 5940 else showCardError('Could not read the AI response: ' + responseText.slice(0,140)); 5941 } 5942 }catch(e){ showCardError('QR parse error: ' + e.message); } 5943} 5944// Build a minimal contact from a raw QR value (URL or text) when AI extraction fails. 5945function _qrFallback(raw){ 5946 raw = String(raw||''); 5947 var isUrl = /^https?:\/\//i.test(raw); 5948 return { firstName:'', lastName:'', fullName:'', title:'', company:'', phone:'', mobile:'', phone2:'', 5949 email:'', email2:'', website: isUrl ? raw : '', address:'', linkedin: /linkedin/i.test(raw)?raw:'', 5950 instagram: /instagram/i.test(raw)?raw:'', facebook: /facebook/i.test(raw)?raw:'', twitter:'', tiktok:'', 5951 whatsapp:'', notes: isUrl ? '' : raw, cardType:'business' }; 5952} 5953 5954// -- DOCUMENT SCANNER --- 5955var scannerDocType = 'auto'; 5956var scannerHistory = []; 5957var lastScanResult = null; 5958var lastScanFile = null; 5959 5960function initScanner(){ 5961 // Present a clean window on every entry. Without this, leaving the tab and coming back 5962 // still shows the previous scan's result card (only a page refresh cleared it). 5963 var res = ge('scannerResults'); if(res){ res.style.display='none'; res.innerHTML=''; } 5964 var proc = ge('scannerProcessing'); if(proc) proc.style.display='none'; 5965 var up = ge('scannerUploadArea'); if(up) up.style.display='block'; 5966 var fi = ge('scannerFileInput'); if(fi) fi.value=''; 5967 lastScanResult = null; 5968 lastScanFile = null; 5969 var hist = ge('scannerHistory'); if(hist) hist.style.display = scannerHistory.length ? 'block' : 'none'; 5970 renderScannerHistory(); 5971} 5972
5973function renderScannerHistory(){ 5974 var el = ge('scannerHistoryList'); 5975 if(!el) return; 5976 if(!scannerHistory.length){ 5977 el.innerHTML = '<div class="empty">No documents scanned yet.</div>'; 5978 return; 5979 } 5980 el.innerHTML = ''; 5981 scannerHistory.slice().reverse().forEach(function(s){ 5982 var row = document.createElement('div'); 5983 row.className = 'scanner-history-item'; 5984 var icon = document.createElement('div'); 5985 icon.style.cssText = 'width:36px;height:36px;background:var(--surface2);border-radius:7px;display:flex;align-items:center;justify-content:center;flex-shrink:0;color:var(--accent);'; 5986 icon.innerHTML = '<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14,2 14,8 20,8"/></svg>'; 5987 var info = document.createElement('div'); 5988 info.style.flex = '1'; 5989 info.innerHTML = '<div class="scanner-history-title">' + (s.docType||'Document') + ' - ' + (s.address||'Unknown property') + '</div>' 5990 + '<div class="scanner-history-sub">' + new Date(s.scannedAt).toLocaleString('en-US',{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) + ' - ' + (s.imported ? 'Imported to CRM' : 'Not imported') + '</div>'; 5991 var badge = document.createElement('span'); 5992 badge.className = 'badge ' + (s.imported ? 'b-ok' : 'b-warn'); 5993 badge.textContent = s.imported ? 'Imported' : 'Pending'; 5994 row.appendChild(icon); row.appendChild(info); row.appendChild(badge); 5995 el.appendChild(row); 5996 }); 5997} 5998 5999function startScan(file){ 6000 if(!file) return; 6001 var maxSize = 20 * 1024 * 1024; // 20MB 6002 if(file.size > maxSize){ alert('File too large. Please use a file under 20MB.'); return; } 6003 lastScanFile = file; 6004 6005 ge('scannerUploadArea').style.display = 'none'; 6006 ge('scannerResults').style.display = 'none'; 6007 ge('scannerProcessing').style.display = 'block'; 6008 ge('scannerHistory').style.display = 'none'; 6009 6010 var docTypeHints = { 6011 auto: 'Analyze this real estate document and determine what type it is.', 6012 repc: 'This is a Utah REPC (Real Estate Purchase Contract).', 6013 addendum: 'This is a real estate addendum or amendment.', 6014 inspection: 'This is a property inspection report.', 6015 title: 'This is a title commitment or title report.', 6016 hoa: 'This is an HOA document.', 6017 other: 'This is a real estate related document.' 6018 }; 6019 6020 var hint = docTypeHints[scannerDocType] || docTypeHints['auto']; 6021 var isPDF = file.type === 'application/pdf'; 6022 6023 var processingMsgs = [ 6024 'Analyzing document...', 6025 'Extracting key information...', 6026 'Identifying deadlines and dates...', 6027 'Generating summary...' 6028 ]; 6029 var msgIdx = 0; 6030 var msgTimer = setInterval(function(){ 6031 msgIdx = (msgIdx + 1) % processingMsgs.length; 6032 var el = ge('scannerProcessingMsg'); 6033 if(el) el.textContent = processingMsgs[msgIdx]; 6034 }, 3000); 6035 6036 var mediaType = file.type || (isPDF ? 'application/pdf' : 'image/jpeg'); 6037 var scanPath = 'scans/' + Date.now() + '_' + String(file.name||'scan').replace(/[^a-zA-Z0-9._-]/g,'_'); 6038 var cleanupScan = function(){ deleteDocFile(scanPath).catch(function(){}); }; 6039 uploadDocFile(file, scanPath).then(async function(){ 6040 var msgContent; 6041 if(isPDF){ 6042 msgContent = [ 6043 {type:'document', source:{type:'storage', media_type:'application/pdf', path:scanPath}}, 6044 {type:'text', text: buildScannerPrompt(hint)} 6045 ]; 6046 } else { 6047 msgContent = [ 6048 {type:'image', source:{type:'storage', media_type:mediaType, path:scanPath}}, 6049 {type:'text', text: buildScannerPrompt(hint)} 6050 ]; 6051 } 6052 return fetch('/api/claude', { 6053 method:'POST', 6054 headers: await apiHeaders(), 6055 body: JSON.stringify({ 6056 model:'claude-sonnet-4-20250514', 6057 max_tokens:8192, 6058 response_format:'json', // force strict JSON so the extraction always parses 6059 messages:[{role:'user', content:msgContent}] 6060 }) 6061 }); 6062 }) 6063 .then(function(r){ return r.json(); }) 6064 .then(function(data){ 6065 clearInterval(msgTimer); 6066 cleanupScan(); 6067 var renderOk = false;
6068 console.log('Gemini raw response:', JSON.stringify(data).substring(0,500)); 6069 if(data.error){ 6070 showScannerError('Gemini API error: ' + (data.error.message || JSON.stringify(data.error))); 6071 return; 6072 } 6073 if(!data.content || !data.content[0]){ 6074 showScannerError('No response from AI. Raw: ' + JSON.stringify(data).substring(0,200)); 6075 return; 6076 } 6077 var text = data.content[0].text; 6078 try{ 6079 console.log('Raw text from Gemini (first 200):', text.substring(0,200)); 6080 var clean = text.trim(); 6081 // Remove markdown fencing 6082 clean = clean.replace(/```json/gi,'').replace(/```/g,'').trim(); 6083 // Find JSON object boundaries 6084 var firstBrace = clean.indexOf('{'); 6085 var lastBrace = clean.lastIndexOf('}'); 6086 if(firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace){ 6087 clean = clean.substring(firstBrace, lastBrace + 1); 6088 } 6089 console.log('Clean JSON (first 200):', clean.substring(0,200)); 6090 var result = JSON.parse(clean); 6091 lastScanResult = result; 6092 lastScanFile = file; 6093 result.scannedAt = new Date().toISOString(); 6094 result.fileName = file.name; 6095 scannerHistory.push(result); 6096 try{ localStorage.setItem('scanner_history', JSON.stringify(scannerHistory.slice(-20))); }catch(e){} 6097 renderOk = true; 6098 }catch(parseErr){ 6099 console.log('Parse error:', parseErr, 'Raw:', text); 6100 // AI responded but not as JSON - show raw response 6101 showScannerRawResult(text); 6102 } 6103 if(renderOk) showScannerResults(result); 6104 }) 6105 .catch(function(err){ 6106 clearInterval(msgTimer); 6107 cleanupScan(); 6108 showScannerError(err.message); 6109 }); 6110} 6111 6112function buildScannerPrompt(hint){ 6113 var lines = [ 6114 hint, 6115 'Analyze this document carefully and extract all available information.', 6116 'Return ONLY a valid JSON object (no markdown, no explanation) with these fields:', 6117 '{', 6118 ' "docType": "REPC|Addendum|Inspection Report|Title Commitment|HOA Docs|Commercial Lease|Commercial Purchase|Agency Disclosure|Seller Disclosure|Lead-Based Paint|Settlement Statement|Appraisal|Loan Approval|Guaranty|Estoppel|Certificate of Insurance|Other",', 6119 ' "dealCategory": "residential_purchase|commercial_lease|commercial_purchase|other",', 6120 ' "summary": "3-5 sentence plain English summary of what this document is and its key points",', 6121 ' "address": "full property address",', 6122 ' "buyerName": "buyer name(s)",', 6123 ' "sellerName": "seller name(s)",', 6124 ' "purchasePrice": "price as string e.g. $450,000",', 6125 ' "loanAmount": "loan amount if present",', 6126 ' "earnestMoney": "earnest money amount",', 6127 ' "contractDate": "YYYY-MM-DD or empty string",', 6128 ' "closingDate": "YYYY-MM-DD or empty string",', 6129 ' "earnestMoneyDeadline": "YYYY-MM-DD or empty string",', 6130 ' "dueDiligenceDeadline": "YYYY-MM-DD or empty string",', 6131 ' "financingDeadline": "YYYY-MM-DD or empty string",', 6132 ' "appraisalDeadline": "YYYY-MM-DD or empty string",', 6133 ' "settlementDeadline": "YYYY-MM-DD or empty string",', 6134 ' "lenderName": "lender name if present",', 6135 ' "titleCompany": "title company if present",', 6136 ' "listingCommissionPct": "listing-side broker commission percent, number only e.g. 3 (empty string if not stated)",', 6137 ' "buyerCommissionPct": "buyer-side broker commission percent, number only e.g. 2.5 (empty string if not stated)",', 6138 ' "mlsNumber": "MLS number if present",', 6139 ' "contingencies": ["list", "of", "contingencies"],', 6140 ' "redFlags": ["any items needing attention", "unusual clauses", "tight deadlines"],', 6141 ' "keyChanges": "for addenda only - what changed from original contract",', 6142 ' "addendumNumber": "for addenda only - the addendum number if stated, digits only e.g. 3 (empty string if not stated)",', 6143 ' "effectiveDate": "for addenda only - the effective or signature date of this addendum, YYYY-MM-DD or empty string",', 6144 ' "inspectionItems": ["for inspection reports - major items found"],', 6145 ' "lease": {', 6146 ' "landlord": "landlord/lessor name",', 6147 ' "tenant": "tenant/lessee name",', 6148 ' "premises": "premises address including suite/unit",', 6149 ' "rentableSqft": "rentable square footage, number only",', 6150 ' "baseRent": "base rent amount, number only e.g. 25 or 4500",', 6151 ' "rentBasis": "per_sqft_month|per_sqft_year|monthly|annual",', 6152 ' "leaseType": "NNN|gross|modified_gross|other",', 6153 ' "camAmount": "CAM / operating-expense amount if stated, number only",', 6154 ' "camBasis": "per_sqft_month|per_sqft_year|monthly|annual (empty if none)",', 6155 ' "leaseTermMonths": "total lease term in months, number only",', 6156 ' "commencementDate": "lease commencement date YYYY-MM-DD",', 6157 ' "rentCommencementDate": "rent commencement date YYYY-MM-DD",', 6158 ' "expirationDate": "lease expiration date YYYY-MM-DD",', 6159 ' "escalation": "rent escalation e.g. 3%/yr or CPI",', 6160 ' "tiAllowance": "tenant improvement allowance if stated",', 6161 ' "securityDeposit": "security deposit amount",', 6162 ' "renewalOptions": "renewal options e.g. two 5-year options",', 6163 ' "renewalOptionDeadline": "option-exercise deadline YYYY-MM-DD if stated",', 6164 ' "guaranty": "personal/corporate guaranty details if any"', 6165 ' },', 6166 ' "financing": {', 6167 ' "type": "conventional|cash|seller|fha|va|other",', 6168 ' "sellerAmount": "principal amount the SELLER is financing, number only (empty if not seller-financed)",', 6169 ' "sellerRate": "annual interest rate percent, number only e.g. 6.5",', 6170 ' "sellerTermMonths": "loan term in months, number only e.g. 360",', 6171 ' "sellerAmortizationMonths": "amortization period in months if different from the term, number only",', 6172 ' "sellerBalloonDate": "balloon / maturity date YYYY-MM-DD if a balloon payment is due",', 6173 ' "sellerMonthlyPayment": "scheduled monthly payment amount, number only",', 6174 ' "sellerDownPayment": "down payment amount, number only",', 6175 ' "sellerLateFee": "late fee terms as stated e.g. 5% after 10 days",', 6176 ' "sellerDueOnSale": "yes|no - whether a due-on-sale / due-on-transfer clause is present"', 6177 ' },', 6178 ' "repc": {', 6179 ' "includedItems": ["checked included-items in REPC 1.2 e.g. Refrigerator, Washer, Dryer"],', 6180 ' "includedItemsOther": "text typed after the Other blank in 1.2",', 6181 ' "personalPropertyIncluded": "are|are not|empty (the ARE / ARE NOT box for personal property in 1.2)",', 6182 ' "personalPropertyList": "personal property items listed in 1.2 when ARE is checked",', 6183 ' "excludedItems": "items excluded from the sale in REPC 1.3",', 6184 ' "waterRightsIncluded": "water rights/shares included per REPC 1.4",', 6185 ' "waterRightsExcluded": "water rights/shares excluded per REPC 1.4",', 6186 ' "possessionBasis": "upon_recording|hours_after_recording|days_after_recording|empty (REPC 3.3)",', 6187 ' "possessionValue": "number of hours or days for possession, digits only (empty if upon_recording)",', 6188 ' "specialAssessmentsWho": "seller|buyer|split|other (who pays special assessments)",', 6189 ' "specialAssessmentsOther": "explanation when specialAssessmentsWho is other",', 6190 ' "hoaTransferFeeWho": "seller|buyer|split|other (REPC 4.3(c) HOA change-of-ownership fees)",', 6191 ' "hoaTransferFeeOther": "explanation when hoaTransferFeeWho is other",', 6192 ' "dueDiligenceApplies": "is|is_not (REPC 8.1 - which box is checked)",', 6193 ' "appraisalApplies": "is|is_not (REPC 8.2 - which box is checked)",', 6194 ' "financingRequired": "yes|no (REPC 8.3: (a) checked = no, (b) checked = yes)",', 6195 ' "earnestHardAmount": "REPC 8.3(b)(i) earnest money that becomes non-refundable after due diligence, digits only (empty or 0 if none)",', 6196 ' "sellerPaidCostsWill": "will|will not (REPC 8.4)",', 6197 ' "sellerPaidCostsAmount": "dollar amount when Will is checked in 8.4, digits only",', 6198 ' "addendaPresent": "are|are not (REPC Section 9 - are addenda attached)",', 6199 ' "addendaNumbers": "addendum number(s) following Addendum No. in Section 9",', 6200 ' "addendaTypes": ["seller_financing|fha_va|other as checked in Section 9"],', 6201 ' "homeWarrantyWill": "will|will not (REPC 10.1)",', 6202 ' "homeWarrantyPays": "buyer|seller (who pays for the home warranty)",', 6203 ' "homeWarrantyOrders": "buyer|seller (who orders the home warranty)",', 6204 ' "homeWarrantySelects": "buyer|seller (who selects the warranty company)",', 6205 ' "homeWarrantyAmount": "estimated home warranty cost, digits only",', 6206 ' "mediation": "shall|may (REPC Section 15 - which box is checked)",', 6207 ' "paymentLines": [{"label":"exact printed label of a Section 2.1 line e.g. New Loan / Seller Financing / Balance of Purchase Price in Cash at Settlement","amount":"dollar amount printed on that line, digits only; 0 if blank"}]', 6208 ' },', 6209 ' "spanishSummary": "same summary in Spanish"', 6210 '}', 6211 'Fill the "repc" object ONLY for a Utah REPC (docType REPC / residential purchase); leave every', 6212 'repc field an empty string (or empty array) for other documents. A checkbox counts as selected', 6213 'if it shows a check mark, an X, or is otherwise filled. For any is/is_not, will/will_not,', 6214 'are/are_not, shall/may, or buyer/seller choice, return the option that is actually marked.', 6215 'financing.type classifies how the purchase is paid. Set "seller" when the SELLER is carrying', 6216 'the financing - look for a Seller Financing Addendum, owner carry / carryback, all-inclusive', 6217 'trust deed (AITD), wrap-around mortgage, contract for deed / land contract, or a promissory', 6218 'note where the seller is the payee/lender. Set "cash" when there is no loan and no seller', 6219 'financing. Set "conventional"/"fha"/"va" for a normal bank/mortgage loan. Fill the seller* note', 6220 'terms ONLY when type is "seller"; leave them empty strings otherwise. Numbers must be digits', 6221 'only. When the deal is seller-financed, add redFlags for any MISSING or unusual note terms:', 6222 'no promissory note/addendum attached, missing interest rate or payment, a balloon due soon,', 6223 'no due-on-sale clause, or an unrecorded trust deed.', 6224 'CRITICAL - Utah REPC Section 2.1 "Payment of Purchase Price": the Purchase Price is shown at the', 6225 'top, then FIVE lines. On each line the dollar amount is printed in a column on the LEFT and its', 6226 'label is to the RIGHT on the SAME row. Read each amount and pair it with the label on its own', 6227 'row. The five lines, in this exact order, are:', 6228 ' (a) Earnest Money Deposit', 6229 ' (b) Additional Earnest Money Deposit', 6230 ' (c) New Loan', 6231 ' (d) Seller Financing (see attached Seller Financing Addendum)', 6232 ' (e) Balance of Purchase Price in Cash at Settlement', 6233 'then PURCHASE PRICE (the total). Transcribe all five into repc.paymentLines as {label, amount},', 6234 'using 0 for a blank line. VALIDATION: (a)+(b)+(c)+(d)+(e) MUST equal the Purchase Price - if your', 6235 'numbers do not add up to it, you mis-read a row; re-read the left-column amounts and fix them', 6236 'before answering. Then set financing.type from the labels, never the letter position:', 6237 ' - the amount on the (d) "Seller Financing" row -> financing.sellerAmount and type="seller";', 6238 ' - the amount on the (c) "New Loan" row -> loanAmount and type="conventional" (or fha/va if an', 6239 ' FHA/VA Loan Addendum is referenced);', 6240 ' - the (e) "Balance ... Cash at Settlement" row is the buyer\'s CASH - never financing or a loan;', 6241 ' - if only the earnest/cash rows have amounts, type="cash".', 6242 'Never swap the (c) New Loan and (d) Seller Financing amounts. If the (d) Seller Financing row has', 6243 'an amount, OR a Seller Financing Addendum is checked in Section 9, the deal IS seller-financed:', 6244 'report it as seller financing in the summary and redFlags - do NOT describe it as a new loan and', 6245 'do NOT report Seller Financing as $0.', 6246 'dealCategory classifies the transaction. Fill the "lease" object ONLY when this is a commercial', 6247 'lease; for a residential purchase leave every "lease" field an empty string and fill the', 6248 'residential fields above instead. Numbers (rentableSqft, baseRent, camAmount, leaseTermMonths)', 6249 'must be digits only, no units or symbols.', 6250 'If a field is not found use an empty string.', 6251 'For dates always use YYYY-MM-DD format.', 6252 'Be thorough - this data will be imported into a real estate CRM.' 6253 ]; 6254 return lines.join('\n'); 6255} 6256 6257function buildContactPicker(containerEl, hiddenId, placeholder, onPick){ 6258 var wrap = document.createElement('div'); 6259 wrap.style.cssText = 'position:relative;';
6260 6261 var hidden = document.createElement('input'); 6262 hidden.type = 'hidden'; 6263 hidden.id = hiddenId; 6264 hidden.value = ''; 6265 6266 var box = document.createElement('input'); 6267 box.type = 'text'; 6268 box.className = 'fi'; 6269 box.setAttribute('autocomplete', 'off'); 6270 box.setAttribute('inputmode', 'search'); 6271 box.placeholder = placeholder || 'Search contacts by name, email, or phone...'; 6272 box.style.fontSize = '16px'; // 16px keeps mobile from zooming and gives a big tap target 6273 box.style.paddingRight = '34px'; // room for the clear button 6274 6275 // Persistent clear/search button. On a phone, once a name is auto-filled there's nothing to 6276 // tap "into" to raise the keyboard - this button empties the field AND focuses it (focusing 6277 // inside a tap reliably opens the mobile keyboard), so you can always search. 6278 var clr = document.createElement('button'); 6279 clr.type = 'button'; 6280 clr.textContent = 'â'; 6281 clr.title = 'Clear and search'; 6282 clr.setAttribute('aria-label', 'Clear and search'); 6283 clr.style.cssText = 'position:absolute;right:6px;top:8px;background:var(--surface2);border:1px solid var(--border);border-radius:50%;width:26px;height:26px;line-height:1;color:var(--text2);cursor:pointer;font-size:13px;padding:0;z-index:3;display:flex;align-items:center;justify-content:center;'; 6284 6285 var menu = document.createElement('div'); 6286 menu.style.cssText = 'position:absolute;left:0;right:0;top:100%;z-index:50;background:var(--surface);border:1px solid var(--border);border-radius:7px;margin-top:3px;max-height:260px;overflow-y:auto;box-shadow:0 6px 20px rgba(0,0,0,0.12);display:none;'; 6287 6288 function norm(s){ 6289 return String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); 6290 } 6291 function digits(s){ return String(s || '').replace(/\D/g, ''); } 6292 6293 function haystack(c){ 6294 var parts = [fn(c)]; 6295 (c.emails || []).forEach(function(e){ parts.push(e && e.value ? e.value : e); }); 6296 if(c.email) parts.push(c.email); 6297 (c.phones || []).forEach(function(p){ parts.push(p && p.value ? p.value : p); }); 6298 if(c.phone) parts.push(c.phone); 6299 return norm(parts.join(' ')); 6300 } 6301 6302 function pick(c){ 6303 hidden.value = c ? c.id : ''; 6304 box.value = c ? fn(c) + (c.email ? ' (' + c.email + ')' : '') : ''; 6305 menu.style.display = 'none'; 6306 if(typeof onPick === 'function') onPick(c); 6307 } 6308 6309 function render(q){ 6310 menu.innerHTML = ''; 6311 var nq = norm(q); 6312 var dq = digits(q); 6313 var matches = []; 6314 for(var i = 0; i < C.length && matches.length < 50; i++){ 6315 var c = C[i]; 6316 var hs = haystack(c); 6317 var toks = nq ? nq.split(/\s+/).filter(Boolean) : []; 6318 var hit = toks.length > 0 && toks.every(function(t){ return hs.indexOf(t) >= 0; }); 6319 if(!hit && dq.length >= 3 && digits(hs).indexOf(dq) >= 0) hit = true; 6320 if(hit) matches.push(c); 6321 } 6322 if(!nq && !dq){ menu.style.display = 'none'; return; } 6323 if(!matches.length){ 6324 var none = document.createElement('div'); 6325 none.style.cssText = 'padding:10px 12px;color:var(--text3);font-size:14px;'; 6326 none.textContent = 'No matches'; 6327 menu.appendChild(none); 6328 menu.style.display = 'block'; 6329 return; 6330 } 6331 matches.forEach(function(c){ 6332 var row = document.createElement('div'); 6333 row.style.cssText = 'padding:9px 12px;cursor:pointer;font-size:14px;color:var(--text);border-bottom:1px solid var(--border);'; 6334 row.addEventListener('mouseenter', function(){ row.style.background = 'var(--surface2)'; }); 6335 row.addEventListener('mouseleave', function(){ row.style.background = 'transparent'; }); 6336 var nm = document.createElement('div'); 6337 nm.textContent = fn(c); 6338 var sub = document.createElement('div'); 6339 sub.style.cssText = 'font-size:12px;color:var(--text3);margin-top:1px;'; 6340 var subBits = []; 6341 if(c.email) subBits.push(c.email); 6342 if(c.phone) subBits.push(c.phone); 6343 sub.textContent = subBits.join(' | '); 6344 row.appendChild(nm); 6345 if(subBits.length) row.appendChild(sub); 6346 row.addEventListener('click', function(){ pick(c); }); 6347 menu.appendChild(row); 6348 }); 6349 menu.style.display = 'block'; 6350 } 6351 6352 box.addEventListener('input', function(){ 6353 hidden.value = ''; 6354 render(box.value); 6355 }); 6356 box.addEventListener('focus', function(){ if(box.value) render(box.value); }); 6357 clr.addEventListener('click', function(e){ 6358 e.preventDefault(); 6359 box.value = ''; hidden.value = ''; menu.style.display = 'none'; 6360 box.focus(); // focus within the tap -> keyboard opens on mobile 6361 }); 6362 document.addEventListener('click', function(e){ 6363 if(!wrap.contains(e.target)) menu.style.display = 'none'; 6364 }); 6365 6366 wrap.appendChild(hidden); 6367 wrap.appendChild(box); 6368 wrap.appendChild(clr); 6369 wrap.appendChild(menu); 6370 containerEl.appendChild(wrap); 6371 return { input: box, hidden: hidden, setContact: pick }; 6372} 6373 6374function showScannerResults(r){ 6375 ge('scannerProcessing').style.display = 'none'; 6376 ge('scannerHistory').style.display = 'block'; 6377 6378 var res = ge('scannerResults'); 6379 res.style.display = 'block'; 6380 res.innerHTML = ''; 6381 6382 var card = document.createElement('div'); 6383 card.className = 'scanner-result'; 6384 6385 // Header 6386 var hdr = document.createElement('div'); 6387 hdr.className = 'scanner-result-header'; 6388 var titleDiv = document.createElement('div'); 6389 var title = document.createElement('div'); 6390 title.className = 'scanner-result-title'; 6391 title.textContent = r.docType || 'Document'; 6392 var addr = document.createElement('div'); 6393 addr.style.cssText = 'font-size:18px;color:var(--text2);margin-top:3px;'; 6394 addr.textContent = r.address || 'Address not found'; 6395 titleDiv.appendChild(title); titleDiv.appendChild(addr); 6396 var typeB = document.createElement('span'); 6397 typeB.className = 'scanner-doc-type'; 6398 typeB.textContent = r.docType || 'Document'; 6399 hdr.appendChild(titleDiv); hdr.appendChild(typeB); 6400 card.appendChild(hdr); 6401 6402 // Summary 6403 if(r.summary){ 6404 var sumDiv = document.createElement('div'); 6405 sumDiv.className = 'scanner-summary'; 6406 var sumLabel = document.createElement('div'); 6407 sumLabel.style.cssText = 'font-size:18px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text3);margin-bottom:6px;'; 6408 sumLabel.textContent = 'Summary'; 6409 var sumText = document.createElement('div'); 6410 sumText.textContent = r.summary; 6411 sumDiv.appendChild(sumLabel); sumDiv.appendChild(sumText); 6412 card.appendChild(sumDiv); 6413 } 6414 6415 // Spanish summary 6416 if(r.spanishSummary){ 6417 var spDiv = document.createElement('div'); 6418 spDiv.className = 'scanner-summary'; 6419 spDiv.style.borderLeftColor = 'var(--seller)'; 6420 var spLabel = document.createElement('div'); 6421 spLabel.style.cssText = 'font-size:18px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text3);margin-bottom:6px;'; 6422 spLabel.textContent = 'Resumen en Espanol'; 6423 var spText = document.createElement('div'); 6424 spText.textContent = r.spanishSummary; 6425 spDiv.appendChild(spLabel); spDiv.appendChild(spText); 6426 card.appendChild(spDiv); 6427 } 6428 6429 // ---- Document type toggle (Residential vs Commercial Lease) ---- 6430 // The AI classifies automatically, but this lets you override a misclassification so the 6431 // right fields always show and import correctly. 6432 var scIsLease = scanIsLease(r); 6433 var typeBar = document.createElement('div');
6434 typeBar.style.cssText = 'display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap;'; 6435 var typeLbl = mkDiv('font-size:13px;color:var(--text3);text-transform:uppercase;letter-spacing:1px;', 'Document type:'); 6436 typeBar.appendChild(typeLbl); 6437 [['Residential',false],['Commercial Lease',true]].forEach(function(opt){ 6438 var b = document.createElement('button'); 6439 var on = (scIsLease === opt[1]); 6440 b.className = 'btn ' + (on ? 'btn-p' : 'btn-g'); 6441 b.style.cssText = 'padding:5px 14px;font-size:14px;'; 6442 b.textContent = opt[0]; 6443 b.addEventListener('click', function(){ r._forceLease = opt[1]; showScannerResults(r); }); 6444 typeBar.appendChild(b); 6445 }); 6446 card.appendChild(typeBar); 6447 6448 // Commercial lease: show the lease field set instead of the residential grid/deadlines. 6449 if(scIsLease){ renderLeaseScanFields(card, r); } 6450 6451 if(!scIsLease){ 6452 // Key fields grid - editable 6453 var fieldsLabel = document.createElement('div'); 6454 fieldsLabel.style.cssText = 'font-size:18px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text3);margin-bottom:8px;'; 6455 fieldsLabel.textContent = 'Extracted Data (edit before importing)'; 6456 card.appendChild(fieldsLabel); 6457 6458 var fields = document.createElement('div'); 6459 fields.className = 'scanner-fields'; 6460 var fieldDefs = [ 6461 {label:'Property Address', key:'address', id:'sc_address'}, 6462 {label:'Buyer Name', key:'buyerName', id:'sc_buyer'}, 6463 {label:'Seller Name', key:'sellerName', id:'sc_seller'}, 6464 {label:'Purchase Price', key:'purchasePrice', id:'sc_price'}, 6465 {label:'Earnest Money', key:'earnestMoney', id:'sc_earnest_amt'}, 6466 {label:'Lender', key:'lenderName', id:'sc_lender'}, 6467 {label:'Title Company', key:'titleCompany', id:'sc_title'}, 6468 {label:'MLS Number', key:'mlsNumber', id:'sc_mls'}, 6469 {label:'Loan Amount', key:'loanAmount', id:'sc_loan'}, 6470 {label:'Listing Commission %', key:'listingCommissionPct', id:'sc_list_comm'}, 6471 {label:'Buyer Commission %', key:'buyerCommissionPct', id:'sc_buyer_comm'} 6472 ]; 6473 // Addendum-only fields: sequencing info, shown when the doc looks like an addendum 6474 var scIsAddendum = !!(r.docType && r.docType.toLowerCase().indexOf('addend')>=0) || !!r.addendumNumber || !!r.keyChanges; 6475 if(scIsAddendum){ 6476 fieldDefs.push({label:'Addendum #', key:'addendumNumber', id:'sc_addendum_no'}); 6477 fieldDefs.push({label:'Addendum Effective Date', key:'effectiveDate', id:'sc_addendum_date'}); 6478 } 6479 fieldDefs.forEach(function(f){ 6480 var fDiv = document.createElement('div'); 6481 fDiv.className = 'scanner-field'; 6482 var lbl = document.createElement('div'); 6483 lbl.className = 'scanner-field-label'; 6484 lbl.textContent = f.label; 6485 var val = document.createElement('input'); 6486 val.className = 'scanner-field-val editable'; 6487 val.id = f.id; 6488 val.value = r[f.key] || ''; 6489 val.placeholder = 'Not found'; 6490 fDiv.appendChild(lbl); fDiv.appendChild(val); 6491 fields.appendChild(fDiv); 6492 }); 6493 card.appendChild(fields); 6494 6495 // Deadlines section 6496 var dlLabel = document.createElement('div'); 6497 dlLabel.style.cssText = 'font-size:18px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text3);margin-bottom:8px;margin-top:4px;'; 6498 dlLabel.textContent = 'Key Dates & Deadlines (edit before importing)'; 6499 card.appendChild(dlLabel); 6500 6501 var dlDiv = document.createElement('div'); 6502 dlDiv.className = 'scanner-deadlines'; 6503 dlDiv.style.cssText = 'background:var(--surface2);border-radius:9px;padding:12px 14px;'; 6504 var deadlineDefs = [ 6505 {label:'Contract Date', key:'contractDate', id:'sc_contract'}, 6506 {label:'Earnest Money Due', key:'earnestMoneyDeadline', id:'sc_earnest_date'}, 6507 {label:'Due Diligence Deadline', key:'dueDiligenceDeadline', id:'sc_duedilig'}, 6508 {label:'Financing Deadline', key:'financingDeadline', id:'sc_financing'}, 6509 {label:'Appraisal Deadline', key:'appraisalDeadline', id:'sc_appraisal'}, 6510 {label:'Closing / Settlement Date', key:'settlementDeadline', id:'sc_closing'} 6511 ]; 6512 deadlineDefs.forEach(function(d){ 6513 var row = document.createElement('div'); 6514 row.className = 'scanner-deadline-row'; 6515 var dot = document.createElement('div'); 6516 dot.className = 'dot ' + (r[d.key] ? 'dot-g' : 'dot-r'); 6517 var lbl = document.createElement('div'); 6518 lbl.style.cssText = 'flex:1;font-size:18px;color:var(--text);'; 6519 lbl.textContent = d.label; 6520 var inp = document.createElement('input'); 6521 inp.id = d.id; 6522 inp.type = 'date'; 6523 inp.value = r[d.key] || ''; 6524 inp.style.cssText = 'background:var(--surface);border:1px solid var(--border);border-radius:5px;padding:4px 8px;font-size:18px;color:var(--text);font-family:DM Sans,sans-serif;outline:none;'; 6525 inp.addEventListener('focus', function(){ this.style.borderColor='var(--accent)'; }); 6526 inp.addEventListener('blur', function(){ this.style.borderColor='var(--border)'; }); 6527 row.appendChild(dot); row.appendChild(lbl); row.appendChild(inp); 6528 dlDiv.appendChild(row); 6529 }); 6530 card.appendChild(dlDiv); 6531 } // end residential-only (fields grid + deadlines) 6532 6533 // Red flags 6534 if(r.redFlags && r.redFlags.length){ 6535 var flagDiv = document.createElement('div'); 6536 flagDiv.className = 'scanner-flags'; 6537 flagDiv.style.marginTop = '12px'; 6538 var flagTitle = document.createElement('div'); 6539 flagTitle.style.cssText = 'font-size:18px;font-weight:700;color:var(--danger);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px;'; 6540 flagTitle.textContent = 'Items Needing Attention'; 6541 flagDiv.appendChild(flagTitle);
6542 r.redFlags.forEach(function(flag){ 6543 var f = document.createElement('div'); 6544 f.className = 'scanner-flag'; 6545 f.innerHTML = '<span style="color:var(--danger);flex-shrink:0;">!</span>' + flag; 6546 flagDiv.appendChild(f); 6547 }); 6548 card.appendChild(flagDiv); 6549 } 6550 6551 // Key changes (addenda) 6552 if(r.keyChanges){ 6553 var chgDiv = document.createElement('div'); 6554 chgDiv.style.cssText = 'background:rgba(76,142,201,0.08);border:1px solid rgba(76,142,201,0.2);border-radius:8px;padding:12px 14px;margin-top:12px;'; 6555 chgDiv.innerHTML = '<div style="font-size:18px;font-weight:700;color:var(--buyer);text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;">Changes from Original Contract</div>' 6556 + '<div style="font-size:18px;color:var(--text2);">' + r.keyChanges + '</div>'; 6557 card.appendChild(chgDiv); 6558 } 6559 6560 // Inspection items 6561 if(r.inspectionItems && r.inspectionItems.length){ 6562 var inspDiv = document.createElement('div'); 6563 inspDiv.style.cssText = 'margin-top:12px;'; 6564 var inspTitle = document.createElement('div'); 6565 inspTitle.style.cssText = 'font-size:18px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text3);margin-bottom:8px;'; 6566 inspTitle.textContent = 'Major Inspection Items'; 6567 inspDiv.appendChild(inspTitle); 6568 r.inspectionItems.forEach(function(item){ 6569 var i = document.createElement('div'); 6570 i.style.cssText = 'font-size:18px;color:var(--text2);padding:4px 0;border-bottom:1px solid var(--border);display:flex;gap:8px;'; 6571 i.innerHTML = '<span style="color:var(--warn);">-</span>' + item; 6572 inspDiv.appendChild(i); 6573 }); 6574 card.appendChild(inspDiv); 6575 } 6576 6577 // ===== Unified Import panel ===== 6578 var imp = document.createElement('div'); 6579 imp.style.cssText = 'margin-top:16px;padding-top:14px;border-top:2px solid var(--border);'; 6580 var impLabel = document.createElement('div'); 6581 impLabel.style.cssText = 'font-size:18px;color:var(--accent);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:10px;font-weight:700;'; 6582 impLabel.textContent = 'Import to CRM'; 6583 imp.appendChild(impLabel); 6584 6585 // ---- Contact resolution ---- 6586 var cLbl = document.createElement('div'); 6587 cLbl.className = 'fl'; cLbl.textContent = 'Contact'; 6588 imp.appendChild(cLbl); 6589 var cWrap = document.createElement('div'); cWrap.style.marginBottom = '4px'; 6590 imp.appendChild(cWrap); 6591 var scPicker = buildContactPicker(cWrap, 'sc_import_contact', 'Search existing contact by name, email, phone...', function(){ if(typeof renderTxChoice==='function') renderTxChoice(); }); 6592 6593 // Pre-seed + client-side selector (buyer vs seller) with smart default 6594 function normName(x){ return String(x||'').normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase().replace(/[^a-z0-9 ]/g,'').replace(/\s+/g,' ').trim(); } 6595 var buyN=(r.buyerName||'').trim(), selN=(r.sellerName||'').trim(); 6596 function scHasMatch(nm){ if(!nm) return false; var d=normName(nm);
6596 var toks=d.split(' ').filter(Boolean); return toks.length>0 && C.some(function(c){ var h=normName(fn(c)); return toks.every(function(t){ return h.indexOf(t)>=0; }); }); } 6597 function scMatch(nm){ nm=(nm||'').trim(); if(!nm) return null; var d=normName(nm); var exact=C.filter(function(c){ return normName(fn(c))===d; }); if(exact.length===1) return exact[0]; var toks=d.split(' ').filter(Boolean); var tokM=C.filter(function(c){ var h=normName(fn(c)); return toks.length>0 && toks.every(function(t){ return h.indexOf(t)>=0; }); }); return tokM.length===1 ? tokM[0] : null; } 6598 var defaultSide='buyer'; 6599 if(r.docType && r.docType.toLowerCase().indexOf('list')>=0) defaultSide='seller'; 6600 if(scHasMatch(selN) && !scHasMatch(buyN)) defaultSide='seller'; 6601 else if(scHasMatch(buyN) && !scHasMatch(selN)) defaultSide='buyer'; 6602 var docName = ((defaultSide==='seller' ? selN : buyN) || buyN || selN || '').trim(); 6603 6604 function scReseed(side){ 6605 if(ge('sc_client_side')) ge('sc_client_side').value = side; 6606 var nm=((side==='seller'?selN:buyN)||'').trim(); 6607 if(nm){ 6608 var hit=scMatch(nm); 6609 if(hit){ scPicker.setContact(hit); } 6610 else { scPicker.hidden.value=''; scPicker.input.value=nm; } 6611 } else { scPicker.hidden.value=''; scPicker.input.value=''; } 6612 var np2=nm.split(' '); 6613 if(ge('sc_nc_first')) ge('sc_nc_first').value=np2[0]||''; 6614 if(ge('sc_nc_last')) ge('sc_nc_last').value=np2.slice(1).join(' '); 6615 if(ge('sc_nc_type')) ge('sc_nc_type').value=side; 6616 } 6617 6618 var sideWrap=document.createElement('div'); sideWrap.style.cssText='margin-bottom:10px;display:flex;align-items:center;gap:10px;'; 6619 var sideLbl=document.createElement('div'); sideLbl.className='fl'; sideLbl.style.marginBottom='0'; sideLbl.textContent='My client is the'; 6620 var sideSel=document.createElement('select'); sideSel.className='fsel'; sideSel.id='sc_client_side'; sideSel.style.width='auto'; sideSel.style.flex='0 0 auto'; 6621 [['buyer','Buyer'],['seller','Seller']].forEach(function(o){ var op=document.createElement('option'); op.value=o[0]; op.textContent=o[1]; sideSel.appendChild(op); }); 6622 sideSel.value=defaultSide; 6623 sideSel.addEventListener('change', function(){ scReseed(sideSel.value); }); 6624 sideWrap.appendChild(sideLbl); sideWrap.appendChild(sideSel); 6625 imp.insertBefore(sideWrap, cLbl); 6626 6627 // Import mode â a seller's listing can receive OFFERS to compare without going under contract. 6628 var modeWrap=document.createElement('div'); modeWrap.id='sc_import_mode_wrap'; modeWrap.style.cssText='margin-bottom:10px;'; 6629 imp.insertBefore(modeWrap, cLbl); 6630 function renderImportMode(){ 6631 modeWrap.innerHTML=''; 6632 var _side = ge('sc_client_side') ? ge('sc_client_side').value : 'buyer'; 6633 if(_side!=='seller' || scanIsLease(r)) return; // offers apply to seller residential listings 6634 modeWrap.appendChild(mkDivSafe('font-size:13px;color:var(--text3);margin-bottom:4px;','This document is')); 6635 [['offer','An offer â add to the listing to compare (stays NOT under contract)'], 6636 ['contract','The ratified contract â put this deal under contract']].forEach(function(o,i){ 6637 var l=document.createElement('label'); l.style.cssText='display:flex;align-items:flex-start;gap:8px;cursor:pointer;font-size:14px;color:var(--text2);margin:3px 0;'; 6638 var rb=document.createElement('input'); rb.type='radio'; rb.name='sc_import_mode'; rb.value=o[0]; rb.style.marginTop='3px'; if(i===0) rb.checked=true; 6639 l.appendChild(rb); l.appendChild(document.createTextNode(o[1])); modeWrap.appendChild(l); 6640 }); 6641 } 6642 renderImportMode(); 6643 sideSel.addEventListener('change', renderImportMode); 6644 6645 if(docName){ 6646 var hit0=scMatch(docName); 6647 if(hit0){ scPicker.setContact(hit0); } 6648 else { scPicker.input.value=docName; } 6649 } 6650 6651 // ---- New-contact toggle ---- 6652 var newToggle = document.createElement('label'); 6653 newToggle.style.cssText = 'display:flex;align-items:center;gap:8px;cursor:pointer;margin:8px 0;font-size:14px;color:var(--text2);'; 6654 var newCb = document.createElement('input'); newCb.type='checkbox'; newCb.id='sc_new_contact_toggle'; 6655 newCb.style.cssText='accent-color:var(--accent);'; 6656 newToggle.appendChild(newCb); 6657 var newTxt = document.createElement('span'); newTxt.textContent = 'This is a new contact - create it'; newToggle.appendChild(newTxt); 6658 imp.appendChild(newToggle); 6659 6660 var newFields = document.createElement('div'); 6661 newFields.style.cssText = 'display:none;background:var(--surface2);border-radius:8px;padding:10px 12px;margin-bottom:8px;'; 6662 var np = docName.split(' '); 6663 function mkNF(id, ph, val){ var i2=document.createElement('input'); i2.className='fi'; i2.id=id; i2.placeholder=ph; i2.value=val||''; i2.style.marginBottom='6px'; return i2; } 6664 var nfRow = document.createElement('div'); nfRow.style.cssText='display:flex;gap:6px;'; 6665 var nfFirst = mkNF('sc_nc_first','First name', np[0]||''); nfFirst.style.flex='1'; 6666 var nfLast = mkNF('sc_nc_last','Last name', np.slice(1).join(' ')); nfLast.style.flex='1'; 6667 nfRow.appendChild(nfFirst); nfRow.appendChild(nfLast); newFields.appendChild(nfRow); 6668 newFields.appendChild(mkNF('sc_nc_phone','Phone (optional)','')); 6669 newFields.appendChild(mkNF('sc_nc_email','Email (optional)','')); 6670 var nfType = document.createElement('select'); nfType.className='fsel'; nfType.id='sc_nc_type';
6671 [['buyer','Buyer'],['seller','Seller']].forEach(function(o){ var op=document.createElement('option'); op.value=o[0]; op.textContent=o[1]; nfType.appendChild(op); }); 6672 nfType.value = defaultSide; 6673 newFields.appendChild(nfType); 6674 imp.appendChild(newFields); 6675 6676 newCb.addEventListener('change', function(){ 6677 newFields.style.display = newCb.checked ? 'block' : 'none'; 6678 scPicker.input.disabled = newCb.checked; 6679 if(newCb.checked){ scPicker.hidden.value=''; scPicker.input.style.opacity='0.5'; } 6680 else { scPicker.input.style.opacity='1'; } 6681 }); 6682 6683 // ---- Transaction resolution ---- 6684 var txLbl = document.createElement('div'); 6685 txLbl.className = 'fl'; txLbl.textContent = 'Transaction'; txLbl.style.marginTop='6px'; 6686 imp.appendChild(txLbl); 6687 var txChoice = document.createElement('div'); 6688 txChoice.style.cssText='margin-bottom:8px;'; 6689 imp.appendChild(txChoice); 6690 6691 // ---- Addendum reconciliation: diff review ---- 6692 var diffWrap = document.createElement('div'); 6693 imp.appendChild(diffWrap); 6694 6695 var SC_DIFF_FIELDS = [ 6696 {id:'sc_price', field:'price', label:'Purchase Price'}, 6697 {id:'sc_contract', field:'contractDate', label:'Contract Date', date:true}, 6698 {id:'sc_closing', field:'closingDate', label:'Closing Date', date:true}, 6699 {id:'sc_earnest_date', field:'earnestDate', label:'Earnest Money Due', date:true}, 6700 {id:'sc_duedilig', field:'dueDiligDate', label:'Due Diligence Deadline', date:true}, 6701 {id:'sc_financing', field:'financingDate', label:'Financing Deadline', date:true}, 6702 {id:'sc_appraisal', field:'appraisalDate', label:'Appraisal Deadline', date:true}, 6703 {id:'sc_lender', field:'lender', label:'Lender'}, 6704 {id:'sc_title', field:'titleCo', label:'Title Company'}, 6705 {id:'sc_mls', field:'mlsNum', label:'MLS #'}, 6706 {id:'sc_list_comm', field:'listCommissionPct', label:'Listing Commission %', pct:true}, 6707 {id:'sc_buyer_comm', field:'buyerCommissionPct', label:'Buyer Commission %', pct:true} 6708 ]; 6709 function scSelectedTx(){ 6710 var el=document.querySelector('input[name="sc_tx_choice"]:checked'); 6711 if(!el || String(el.value).indexOf('existing:')!==0) return null; 6712 var exid=String(el.value).split(':')[1]; 6713 return TX.find(function(t){ return String(t.id)===String(exid); }) || null; 6714 } 6715 function scIncomingNo(){ 6716 var e=ge('sc_addendum_no'); if(!e) return null; 6717 var n=parseFloat(String(e.value).replace(/[^0-9.]/g,'')); 6718 return isNaN(n) ? null : n; 6719 } 6720 // Highest addendum number that has already changed this field on this transaction 6721 function scFieldLatestNo(txId, field){ 6722 var best=null; 6723 CH.forEach(function(h){ 6724 if(String(h.transactionId)!==String(txId) || h.field!==field) return; 6725 var n=(h.addendumNo==null ? null : parseFloat(h.addendumNo)); 6726 if(n!=null && !isNaN(n) && (best==null || n>best)) best=n; 6727 }); 6728 return best; 6729 } 6730 function scNormVal(v, f){ 6731 if(v==null) return ''; 6732 var s=String(v).trim(); 6733 if(f && f.pct){ var p=normPct(s); return p==null ? '' : String(p); } 6734 return s; 6735 } 6736 function renderDiff(){ 6737 if(!diffWrap) return; // guard against an early call before the UI is built 6738 diffWrap.innerHTML=''; 6739 var dtx=scSelectedTx(); 6740 if(!dtx) return; 6741 // Contract terms (title company, dates, price, lender, commission, MLS) are amended ONLY by an 6742 // ADDENDUM. A title-insurance commitment, inspection report, disclosure, etc. â even one from a 6743 // company with "title" in its name â must never change the deal's fields. The document still 6744 // attaches to the transaction; it just doesn't propose field changes. 6745 if(String(r.docType||'').toLowerCase().indexOf('addendum')<0) return; 6746 var inNo=scIncomingNo(); 6747 var rows=[]; 6748 SC_DIFF_FIELDS.forEach(function(f){ 6749 var el=ge(f.id); if(!el) return; 6750 var nv=scNormVal(el.value, f); 6751 if(nv==='') return; // nothing extracted -> not a change 6752 var cv=scNormVal(dtx[f.field], f); 6753 if(nv===cv) return; // identical -> not a change (handles echoed terms) 6754 var latest=scFieldLatestNo(dtx.id, f.field); 6755 var stale=(inNo!=null && latest!=null && inNo<latest); 6756 rows.push({f:f, cur:cv, nw:nv, stale:stale, latest:latest}); 6757 }); 6758 if(!rows.length) return; 6759 var dh=document.createElement('div'); 6760 dh.className='fl'; dh.style.marginTop='8px'; 6761 dh.textContent='Changes to apply'+(inNo!=null ? ' (addendum #'+inNo+')' : ''); 6762 diffWrap.appendChild(dh); 6763 var box=document.createElement('div'); 6764 box.style.cssText='background:var(--surface2);border-radius:8px;padding:8px 10px;margin-bottom:8px;'; 6765 rows.forEach(function(row){ 6766 var line=document.createElement('label'); 6767 line.style.cssText='display:flex;align-items:center;gap:8px;padding:4px 0;font-size:13px;
6767cursor:pointer;border-bottom:1px solid var(--border);'; 6768 var cb=document.createElement('input'); cb.type='checkbox'; cb.className='sc_diff_cb'; 6769 cb.setAttribute('data-field', row.f.field); 6770 cb.setAttribute('data-id', row.f.id); 6771 cb.checked = !row.stale; // stale edits are opt-in, never default 6772 cb.style.cssText='accent-color:var(--accent);flex:0 0 auto;'; 6773 var t=document.createElement('div'); t.style.flex='1'; 6774 t.innerHTML='<b style="color:var(--text);">'+row.f.label+'</b> ' 6775 +'<span style="color:var(--text3);">'+(row.cur==='' ? '(empty)' : row.cur)+'</span>' 6776 +'<span style="color:var(--accent);"> → </span>' 6777 +'<span style="color:var(--text);font-weight:600;">'+row.nw+'</span>'; 6778 line.appendChild(cb); line.appendChild(t); 6779 if(row.stale){ 6780 var w=document.createElement('div'); 6781 w.style.cssText='color:var(--danger);font-size:11px;white-space:nowrap;'; 6782 w.textContent='older than #'+row.latest; 6783 line.appendChild(w); 6784 } 6785 box.appendChild(line); 6786 }); 6787 diffWrap.appendChild(box); 6788 if(rows.some(function(x){ return x.stale; })){ 6789 var warn=document.createElement('div'); 6790 warn.style.cssText='color:var(--danger);font-size:12px;margin:-4px 0 8px;'; 6791 warn.textContent='Some fields were already changed by a NEWER addendum. Those are unchecked - tick them only if this document really should win.'; 6792 diffWrap.appendChild(warn); 6793 } 6794 } 6795 6796 function renderTxChoice(){ 6797 if(!txChoice || !newCb) return; // an auto-link during setup can fire this before the UI exists 6798 var prev = (document.querySelector('input[name="sc_tx_choice"]:checked')||{}).value || ''; // keep the user's pick across re-renders 6799 txChoice.innerHTML = ''; 6800 var cid = newCb.checked ? null : (scPicker.hidden.value ? parseInt(scPicker.hidden.value) : null); 6801 var existing = cid ? TX.filter(function(t){ return String(t.contactId)===String(cid) && t.status!=='closed'; }) : []; 6802 // Auto-attach: if the scanned address clearly matches one of this contact's existing deals, 6803 // pre-select it (so a second offer lands on the same listing without extra clicks). 6804 var _na=function(s){ return String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,' ').trim(); }; 6805 var scAddr=_na((ge('sc_address')&&ge('sc_address').value) ? ge('sc_address').value : (r.address||'')); 6806 var autoId=null; 6807 existing.forEach(function(t){ if(autoId==null && scAddr){ var a=_na(t.address); if(a && (a.indexOf(scAddr)>=0 || scAddr.indexOf(a)>=0)) autoId=t.id; } }); 6808 var validPrev = prev==='new' || existing.some(function(t){ return ('existing:'+t.id)===prev; }); 6809 var sel = validPrev ? prev : (autoId!=null ? ('existing:'+autoId) : 'new'); 6810 6811 function radio(val, labelTxt, checked, onChg){ 6812 var lab=document.createElement('label'); 6813 lab.style.cssText='display:flex;align-items:center;gap:8px;cursor:pointer;padding:5px 0;font-size:14px;color:var(--text);'; 6814 var rb=document.createElement('input'); rb.type='radio'; rb.name='sc_tx_choice'; rb.value=val; rb.checked=checked; 6815 rb.style.cssText='accent-color:var(--accent);'; 6816 rb.addEventListener('change', onChg||renderTxChoice); 6817 lab.appendChild(rb); lab.appendChild(document.createTextNode(labelTxt)); 6818 return lab; 6819 } 6820 txChoice.appendChild(radio('new', 'Create new transaction', sel==='new')); 6821 if(existing.length){ 6822 var exWrap=document.createElement('div'); 6823 exWrap.style.cssText='margin-top:4px;padding-left:4px;'; 6824 existing.forEach(function(t){ 6825 var lab=document.createElement('label'); 6826 lab.style.cssText='display:flex;align-items:center;gap:8px;cursor:pointer;padding:4px 0;font-size:13px;color:var(--text2);'; 6827 var rb=document.createElement('input'); rb.type='radio'; rb.name='sc_tx_choice'; rb.value='existing:'+t.id; rb.checked=(sel==='existing:'+t.id); 6828 rb.style.cssText='accent-color:var(--accent);'; 6829 rb.addEventListener('change', renderDiff); 6830 lab.appendChild(rb); 6831 lab.appendChild(document.createTextNode((t.address||'Unknown address')+' ('+(t.type||'')+')'+((autoId===t.id)?' â matches this document':''))); 6832 exWrap.appendChild(lab); 6833 }); 6834 var exHdr=document.createElement('div'); 6835 exHdr.style.cssText='font-size:12px;color:var(--text3);margin-top:6px;'; 6836 exHdr.textContent = (autoId!=null && !validPrev) ? 'auto-attached to the matching deal (change if wrong):' : 'or add to an existing transaction for this contact:'; 6837 txChoice.appendChild(exHdr); 6838 txChoice.appendChild(exWrap); 6839 } 6840 renderDiff(); 6841 } 6842 renderTxChoice(); 6843 SC_DIFF_FIELDS.forEach(function(f){ var el=ge(f.id); if(el) el.addEventListener('input', renderDiff); }); 6844 if(ge('sc_addendum_no')) ge('sc_addendum_no').addEventListener('input', renderDiff); 6845 scPicker.input.addEventListener('blur', function(){ setTimeout(renderTxChoice, 200); }); 6846 6847 // ---- Single action + result state ---- 6848 var impActions = document.createElement('div'); 6849 impActions.className = 'scanner-actions'; 6850 var commitBtn = document.createElement('button'); 6851 commitBtn.className = 'btn btn-p'; commitBtn.style.fontSize='14px';
6852 commitBtn.id = 'sc_commit_btn'; 6853 commitBtn.textContent = 'Confirm & Import'; 6854 commitBtn.addEventListener('click', function(){ commitScanImport(r, commitBtn); }); 6855 var newScanBtn = document.createElement('button'); 6856 newScanBtn.className='btn btn-g'; newScanBtn.textContent='Scan Another Document'; 6857 newScanBtn.addEventListener('click', function(){ 6858 ge('scannerUploadArea').style.display='block'; 6859 ge('scannerResults').style.display='none'; 6860 ge('scannerHistory').style.display='block'; 6861 ge('scannerFileInput').value=''; 6862 }); 6863 impActions.appendChild(commitBtn); impActions.appendChild(newScanBtn); 6864 imp.appendChild(impActions); 6865 6866 var resultState = document.createElement('div'); 6867 resultState.id = 'sc_result_state'; 6868 resultState.style.cssText='display:none;margin-top:10px;padding:10px 12px;background:rgba(30,76,132,0.08);border:1px solid var(--accent);border-radius:8px;'; 6869 imp.appendChild(resultState); 6870 6871 card.appendChild(imp); 6872 6873 // Full notes section showing complete extracted text 6874 var notesSec = document.createElement('div'); 6875 notesSec.style.cssText = 'background:var(--surface2);border-radius:9px;padding:14px 16px;margin-top:12px;'; 6876 var notesTitle = document.createElement('div'); 6877 notesTitle.style.cssText = 'font-size:18px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text3);margin-bottom:8px;'; 6878 notesTitle.textContent = 'Full Transaction Notes (saved to CRM)'; 6879 var notesBody = document.createElement('div'); 6880 notesBody.style.cssText = 'font-size:18px;color:var(--text2);line-height:1.7;white-space:pre-wrap;word-break:break-word;'; 6881 var NL3 = '\n'; 6882 var fullNotes = 'Imported from document scan: ' + (r.docType||'Document') + '. ' + (r.summary||''); 6883 if(r.keyChanges) fullNotes += NL3 + NL3 + 'Key Changes: ' + r.keyChanges; 6884 if(r.contingencies && r.contingencies.length) fullNotes += NL3 + NL3 + 'Contingencies: ' + r.contingencies.join(', '); 6885 if(r.redFlags && r.redFlags.length) fullNotes += NL3 + NL3 + 'Items Needing Attention:' + NL3 + '- ' + r.redFlags.join(NL3 + '- '); 6886 notesBody.textContent = fullNotes; 6887 notesSec.appendChild(notesTitle); notesSec.appendChild(notesBody); 6888 card.appendChild(notesSec); 6889 6890 var copyBtn = document.createElement('button'); 6891 copyBtn.className = 'btn btn-g'; 6892 copyBtn.textContent = 'Copy Summary'; 6893 copyBtn.addEventListener('click', function(){ 6894 var NL2 = '\n'; 6895 var txt = (r.docType||'Document') + NL2 + (r.address||'') + NL2 + NL2 + (r.summary||'') + NL2 + NL2 + 'Espanol:' + NL2 + (r.spanishSummary||''); 6896 navigator.clipboard && navigator.clipboard.writeText(txt); 6897 copyBtn.textContent = 'Copied!'; 6898 setTimeout(function(){ copyBtn.textContent = 'Copy Summary'; }, 2000); 6899 }); 6900 card.appendChild(copyBtn); 6901 res.appendChild(card); 6902 renderScannerHistory(); 6903} 6904 6905async function saveScanDocument(r, btn){ 6906 if(!lastScanFile){ alert('The original file is no longer in memory. Please re-scan the document, then Save.'); return; } 6907 var cidRaw = ge('sc_doc_contact') ? ge('sc_doc_contact').value : ''; 6908 var tidRaw = ge('sc_doc_tx') ? ge('sc_doc_tx').value : ''; 6909 var contact_id = cidRaw ? parseInt(cidRaw) : null; 6910 var transaction_id = tidRaw ? parseInt(tidRaw) : null; 6911 if(!contact_id && !transaction_id){ 6912 alert('Please link this document to a contact or transaction before saving.'); 6913 return; 6914 } 6915 if(transaction_id && !contact_id){ 6916 var tx = TX.find(function(t){ return String(t.id) === String(transaction_id); }); 6917 if(tx && tx.contactId) contact_id = tx.contactId; 6918 } 6919 var origText = btn ? btn.textContent : ''; 6920 if(btn){ btn.textContent = 'Saving...'; btn.disabled = true; } 6921 try{ 6922 await saveDocument(lastScanFile, { 6923 contact_id: contact_id, 6924 transaction_id: transaction_id, 6925 doc_type: r.docType || 'Document', 6926 summary: r.summary || '', 6927 extracted: buildDocExtract(r) 6928 }); 6929 if(btn){ btn.textContent = 'Saved!'; } 6930 alert('Document saved to CRM.'); 6931 }catch(e){ 6932 if(btn){ btn.textContent = origText; btn.disabled = false; } 6933 alert('Save failed: ' + e.message); 6934 } 6935} 6936 6937// Normalize the AI's raw lease object into a clean, typed details bag for storage/display. 6938function normalizeLease(L){ 6939 L = L || {}; 6940 function s(v){ return (v==null?'':String(v)).trim(); } 6941 function num(v){ var x=parseFloat(String(v==null?'':v).replace(/[^0-9.]/g,'')); return isNaN(x)?'':x; } 6942 return { 6943 landlord:s(L.landlord), tenant:s(L.tenant), premises:s(L.premises), 6944 rentableSqft:num(L.rentableSqft), baseRent:num(L.baseRent), 6945 rentBasis:s(L.rentBasis)||'per_sqft_month', leaseType:s(L.leaseType)||'NNN', 6946 camAmount:num(L.camAmount), camBasis:s(L.camBasis), leaseTermMonths:num(L.leaseTermMonths), 6947 commencementDate:s(L.commencementDate), rentCommencementDate:s(L.rentCommencementDate), 6948 expirationDate:s(L.expirationDate), escalation:s(L.escalation), tiAllowance:s(L.tiAllowance), 6949 securityDeposit:s(L.securityDeposit), renewalOptions:s(L.renewalOptions), 6950 renewalOptionDeadline:s(L.renewalOptionDeadline), guaranty:s(L.guaranty) 6951 }; 6952} 6953 6954// Single source of truth for "is this scan a commercial lease?" - honors a manual override 6955// (_forceLease) set by the preview toggle, else auto-detects from the AI's classification. 6956function scanIsLease(r){ 6957 if(r._forceLease === true) return true; 6958 if(r._forceLease === false) return false;
6959 return (r.dealCategory === 'commercial_lease') || (r.docType && /lease/i.test(r.docType)) || 6960 !!(r.lease && (r.lease.tenant || r.lease.landlord || r.lease.baseRent || r.lease.premises || r.lease.rentableSqft)); 6961} 6962 6963// Read the (editable) lease fields from the scan preview, falling back to the AI's extraction. 6964function readLeaseForm(r){ 6965 var base = normalizeLease(r.lease || {}); 6966 function g(id, cur){ var el = ge(id); return el ? el.value : cur; } 6967 return normalizeLease({ 6968 landlord:g('sc_lease_landlord',base.landlord), tenant:g('sc_lease_tenant',base.tenant), 6969 premises:g('sc_lease_premises',base.premises), rentableSqft:g('sc_lease_sqft',base.rentableSqft), 6970 baseRent:g('sc_lease_baserent',base.baseRent), rentBasis:g('sc_lease_rentbasis',base.rentBasis), 6971 leaseType:g('sc_lease_type',base.leaseType), camAmount:g('sc_lease_cam',base.camAmount), 6972 camBasis:g('sc_lease_cambasis',base.camBasis), leaseTermMonths:g('sc_lease_term',base.leaseTermMonths), 6973 commencementDate:g('sc_lease_commencement',base.commencementDate), 6974 rentCommencementDate:g('sc_lease_rentcommence',base.rentCommencementDate), 6975 expirationDate:g('sc_lease_expiration',base.expirationDate), 6976 escalation:g('sc_lease_escalation',base.escalation), tiAllowance:g('sc_lease_ti',base.tiAllowance), 6977 securityDeposit:g('sc_lease_deposit',base.securityDeposit), renewalOptions:g('sc_lease_options',base.renewalOptions), 6978 renewalOptionDeadline:g('sc_lease_optiondeadline',base.renewalOptionDeadline), guaranty:g('sc_lease_guaranty',base.guaranty) 6979 }); 6980} 6981 6982// Editable commercial-lease section for the scan preview. 6983function renderLeaseScanFields(card, r){ 6984 var L = normalizeLease(r.lease || {}); 6985 var lbl = document.createElement('div'); 6986 lbl.style.cssText = 'font-size:18px;text-transform:uppercase;letter-spacing:1.5px;color:var(--seller);margin-bottom:8px;'; 6987 lbl.textContent = 'Commercial Lease â Extracted (edit before importing)'; 6988 card.appendChild(lbl); 6989 var grid = document.createElement('div'); 6990 grid.className = 'scanner-fields'; 6991 function field(id,label,val){ 6992 var d=document.createElement('div'); d.className='scanner-field'; 6993 var l=document.createElement('div'); l.className='scanner-field-label'; l.textContent=label; 6994 var i=document.createElement('input'); i.className='scanner-field-val editable'; i.id=id; 6995 i.value=(val==null?'':String(val)); i.placeholder='Not found'; 6996 d.appendChild(l); d.appendChild(i); return d; 6997 } 6998 [
6999 ['sc_lease_landlord','Landlord',L.landlord], 7000 ['sc_lease_tenant','Tenant',L.tenant], 7001 ['sc_lease_premises','Premises',L.premises], 7002 ['sc_lease_sqft','Rentable SF',L.rentableSqft], 7003 ['sc_lease_baserent','Base Rent',L.baseRent], 7004 ['sc_lease_rentbasis','Rent Basis (per_sqft_month / per_sqft_year / monthly / annual)',L.rentBasis], 7005 ['sc_lease_type','Lease Type (NNN / gross / modified_gross)',L.leaseType], 7006 ['sc_lease_cam','CAM Amount',L.camAmount], 7007 ['sc_lease_cambasis','CAM Basis',L.camBasis], 7008 ['sc_lease_term','Term (months)',L.leaseTermMonths], 7009 ['sc_lease_escalation','Escalation',L.escalation], 7010 ['sc_lease_ti','TI Allowance',L.tiAllowance], 7011 ['sc_lease_deposit','Security Deposit',L.securityDeposit], 7012 ['sc_lease_options','Renewal Options',L.renewalOptions], 7013 ['sc_lease_guaranty','Guaranty',L.guaranty] 7014 ].forEach(function(f){ grid.appendChild(field(f[0],f[1],f[2])); }); 7015 card.appendChild(grid); 7016 7017 var dLbl=document.createElement('div'); 7018 dLbl.style.cssText='font-size:18px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text3);margin:10px 0 8px;'; 7019 dLbl.textContent='Lease Milestones (edit before importing)'; 7020 card.appendChild(dLbl); 7021 var dwrap=document.createElement('div'); dwrap.style.cssText='background:var(--surface2);border-radius:9px;padding:12px 14px;'; 7022 function dateRow(id,label,val){ 7023 var row=document.createElement('div'); row.className='scanner-deadline-row'; 7024 var dot=document.createElement('div'); dot.className='dot '+(val?'dot-g':'dot-r'); 7025 var l=document.createElement('div'); l.style.cssText='flex:1;font-size:18px;color:var(--text);'; l.textContent=label; 7026 var i=document.createElement('input'); i.id=id; i.type='date'; i.value=val||''; 7027 i.style.cssText='background:var(--surface);border:1px solid var(--border);border-radius:5px;padding:4px 8px;font-size:18px;color:var(--text);font-family:DM Sans,sans-serif;outline:none;'; 7028 row.appendChild(dot); row.appendChild(l); row.appendChild(i); return row; 7029 } 7030 dwrap.appendChild(dateRow('sc_lease_commencement','Lease Commencement',L.commencementDate)); 7031 dwrap.appendChild(dateRow('sc_lease_rentcommence','Rent Commencement',L.rentCommencementDate)); 7032 dwrap.appendChild(dateRow('sc_lease_expiration','Lease Expiration',L.expirationDate)); 7033 dwrap.appendChild(dateRow('sc_lease_optiondeadline','Renewal Option Deadline',L.renewalOptionDeadline)); 7034 card.appendChild(dwrap); 7035} 7036 7037async function commitScanImport(r, btn){ 7038 // ---- Require a property address up front (before creating anything) ---- 7039 // A transaction IS a property/address. It must come from the scan or be typed in; we never assume 7040 // the contact's address (which may be a prior/other property). Block here so no contact or deal is 7041 // created without one, and so retrying doesn't duplicate a just-created contact. 7042 var _scAddr = ge('sc_address') ? ge('sc_address').value.trim() : (r.address||''); 7043 var _txChoiceEl0 = document.querySelector('input[name="sc_tx_choice"]:checked'); 7044 var _txChoice0 = _txChoiceEl0 ? _txChoiceEl0.value : 'new'; 7045 var _updatingExisting = false; 7046 if(_txChoice0.indexOf('existing:')===0){ var _exid0=_txChoice0.split(':')[1]; _updatingExisting = !!TX.find(function(t){ return String(t.id)===String(_exid0); }); } 7047 if(!_updatingExisting && !_scAddr){ alert('This transaction needs a property address before it can be created.\n\nEnter it in the âProperty Addressâ field (from the scan or typed in). The contactâs address is never assumed.'); return; } 7048 7049 // ---- Resolve contact (required) ---- 7050 var newCb = ge('sc_new_contact_toggle'); 7051 var contactId = null; 7052 7053 if(newCb && newCb.checked){ 7054 var first = (ge('sc_nc_first').value||'').trim(); 7055 var last = (ge('sc_nc_last').value||'').trim(); 7056 if(!first && !last){ alert('Enter a name for the new contact, or pick an existing one.'); return; } 7057 var newC = { 7058 id: Date.now() + Math.floor(Math.random()*100000), 7059 first: first, last: last, 7060 type: ge('sc_nc_type').value || 'buyer', 7061 phone: (ge('sc_nc_phone').value||'').trim(), 7062 email: (ge('sc_nc_email').value||'').trim(), 7063 emails: [], phones: [], addresses: [], 7064 property: r.address||'', 7065 stage: '', 7066 price: ge('sc_price') ? ge('sc_price').value : (r.purchasePrice||''), 7067 notes: 'Created from document scan.', 7068 added: new Date().toISOString() 7069 }; 7070 if(newC.email) newC.emails=[{value:newC.email,label:''}]; 7071 if(newC.phone) newC.phones=[{value:newC.phone,label:''}]; 7072 C.push(newC); saveContact(newC); logActivity(newC.id,'Contact created from scan'); 7073 contactId = newC.id; 7074 } else { 7075 var picked = ge('sc_import_contact') ? ge('sc_import_contact').value : ''; 7076 if(!picked){ alert('Select an existing contact, or check "This is a new contact" to create one.'); return; } 7077 contactId = parseInt(picked); 7078 } 7079 7080 // ---- Gather edited fields ---- 7081 var price = ge('sc_price') ? ge('sc_price').value : (r.purchasePrice||''); 7082 var lender = ge('sc_lender') ? ge('sc_lender').value : (r.lenderName||'');
7083 var titleCo = ge('sc_title') ? ge('sc_title').value : (r.titleCompany||''); 7084 var mlsNum = ge('sc_mls') ? ge('sc_mls').value : (r.mlsNumber||''); 7085 var contractDate = ge('sc_contract') ? ge('sc_contract').value : (r.contractDate||''); 7086 var closingDate = ge('sc_closing') ? ge('sc_closing').value : (r.settlementDeadline||''); 7087 var earnestDate = ge('sc_earnest_date') ? ge('sc_earnest_date').value : (r.earnestMoneyDeadline||''); 7088 var dueDiligDate = ge('sc_duedilig') ? ge('sc_duedilig').value : (r.dueDiligenceDeadline||''); 7089 var financingDate = ge('sc_financing') ? ge('sc_financing').value : (r.financingDeadline||''); 7090 var appraisalDate = ge('sc_appraisal') ? ge('sc_appraisal').value : (r.appraisalDeadline||''); 7091 var listCommPct = ge('sc_list_comm') ? ge('sc_list_comm').value : (r.listingCommissionPct||''); 7092 var buyerCommPct = ge('sc_buyer_comm') ? ge('sc_buyer_comm').value : (r.buyerCommissionPct||''); 7093 var address = ge('sc_address') ? ge('sc_address').value.trim() : (r.address||''); 7094 7095 // A commercial lease is a different transaction shape - detect it so we store lease terms and 7096 // lease milestones instead of the residential purchase fields. 7097 var isLease = scanIsLease(r); 7098 7099 // ---- OFFER PATH: a seller's listing receiving a competing offer. Inert: no under-contract, no 7100 // deadlines, no pipeline move. The offer is stored on the listing for side-by-side compare. ---- 7101 var _clientSide = ge('sc_client_side') ? ge('sc_client_side').value : 'buyer'; 7102 var _importMode = (document.querySelector('input[name="sc_import_mode"]:checked')||{}).value || 'contract'; 7103 if(_clientSide==='seller' && !isLease && _importMode==='offer'){ 7104 var _choiceEl0 = document.querySelector('input[name="sc_tx_choice"]:checked'); 7105 var _choice0 = _choiceEl0 ? _choiceEl0.value : 'new'; 7106 var listTx = null; 7107 if(_choice0.indexOf('existing:')===0){ var _exId0=_choice0.split(':')[1]; listTx = TX.find(function(t){ return String(t.id)===String(_exId0); }); } 7108 if(!listTx){ 7109 if(!address){ alert('This listing needs a property address before it can be created. Enter it in the âProperty Addressâ field above (from the scan or typed in).'); return; } 7110 listTx = { id: Date.now()+Math.floor(Math.random()*100000), contactId: contactId, type:'seller', address: address, status:'active', steps:{}, notes:'Listing (created from a scanned offer).', details:{} }; 7111 TX.push(listTx); 7112 } 7113 if(!listTx.address && address) listTx.address = address; 7114 var _fin0 = r.financing || {}; 7115 var _cls0 = (typeof classifyFinancingFromLines==='function') ? classifyFinancingFromLines((r.repc&&r.repc.paymentLines)||_fin0.paymentLines) : null; 7116 var _sellerAdd0 = !!(r.repc && Array.isArray(r.repc.addendaTypes) && r.repc.addendaTypes.indexOf('seller_financing')>=0); 7117 var _offFin = (_sellerAdd0 || (_cls0&&_cls0.type==='seller') || normFinancingType(_fin0.type)==='seller') ? 'seller' 7118 : (_cls0 ? _cls0.type : (normFinancingType(_fin0.type) || 'conventional')); 7119 var _sfo = (typeof sfTermsFromScan==='function') ? (sfTermsFromScan(_fin0)||{}) : {}; 7120 if(_cls0 && _cls0.sellerAmount && !_sfo.amount) _sfo.amount=_cls0.sellerAmount; 7121 var _offer = { 7122 id: Date.now()+Math.floor(Math.random()*100000)+7, 7123 buyer: (r.buyerName||'').trim(), buyerAgent: (r.buyerAgent||'').trim(), 7124 offerDate: contractDate || tod(), price: price, financingType: _offFin, 7125 earnest: (r.earnestMoneyAmount||''), 7126 closingDate: closingDate, dueDiligenceDate: dueDiligDate, financingDate: financingDate, appraisalDate: appraisalDate, 7127 concessions: '', contingencies: '', status: 'active', 7128 sf: { down:_sfo.down, rate:_sfo.rate, termMonths:_sfo.term, balloonMonths:_sfo.balloon, monthly:_sfo.monthly }, 7129 notes: 'Imported from scan: '+(r.docType||'Offer') 7130 }; 7131 listTx.details = listTx.details || {}; 7132 listTx.details.offers = (Array.isArray(listTx.details.offers)?listTx.details.offers:[]).concat([_offer]); 7133 saveTX(listTx); 7134 var _oNote = {id:Date.now()+Math.floor(Math.random()*100000)+8, contactId:contactId, transactionId:listTx.id, text:'Offer scanned: '+(r.docType||'Document')+'. '+(r.summary||''), date:new Date().toISOString(), category:'Document'}; 7135 N.push(_oNote); saveNote(_oNote); logActivity(contactId,'Offer added from scan'); 7136 r.imported = true; renderScannerHistory(); rd(); renderTC(); 7137 var _ofWarn=''; 7138 if(lastScanFile){ 7139 if(btn){ btn.textContent='Storing file...'; btn.disabled=true; }
7140 try{ await saveDocument(lastScanFile, { contact_id:contactId, transaction_id:listTx.id, doc_type:r.docType||'Offer', summary:r.summary||'', extracted:buildDocExtract(r) }); } 7141 catch(e){ _ofWarn=' (file not stored: '+e.message+')'; } 7142 } 7143 var _rs0=ge('sc_result_state'); 7144 if(_rs0){ _rs0.style.display='block'; _rs0.innerHTML=''; 7145 var _who0=gc(contactId); 7146 _rs0.appendChild(mkDivSafe('font-size:14px;color:var(--text);margin-bottom:8px;', _esc('Added as an offer to '+(_who0?fn(_who0):'the listing')+'. The deal is NOT under contract.'+_ofWarn))); 7147 var _vb0=document.createElement('button'); _vb0.className='btn btn-p'; _vb0.style.fontSize='13px'; _vb0.textContent='View listing'; 7148 _vb0.addEventListener('click', function(){ sp('tc'); if(typeof openTCDetail==='function') openTCDetail(listTx.id); }); _rs0.appendChild(_vb0); 7149 } 7150 if(btn){ btn.textContent='Added as offer'; btn.disabled=true; } 7151 return; 7152 } 7153 7154 // ---- Resolve transaction choice ---- 7155 var choiceEl = document.querySelector('input[name="sc_tx_choice"]:checked'); 7156 var choice = choiceEl ? choiceEl.value : 'new'; 7157 var tx = null; 7158 7159 if(choice.indexOf('existing:') === 0){ 7160 var exId = choice.split(':')[1]; 7161 tx = TX.find(function(t){ return String(t.id)===String(exId); }); 7162 } 7163 7164 if(tx){ 7165 // ---- Addendum reconciliation ---- 7166 // Apply ONLY what was accepted in the diff review, and log every applied change. 7167 var accNo = null, accDate = ''; 7168 var _anEl = ge('sc_addendum_no'); 7169 if(_anEl){ var _n = parseFloat(String(_anEl.value).replace(/[^0-9.]/g,'')); if(!isNaN(_n)) accNo = _n; } 7170 var _adEl = ge('sc_addendum_date'); if(_adEl && _adEl.value) accDate = _adEl.value; 7171 7172 var _boxes = document.querySelectorAll('.sc_diff_cb'); 7173 var _applied = 0; 7174 Array.prototype.forEach.call(_boxes, function(cb){ 7175 if(!cb.checked) return; 7176 var fld = cb.getAttribute('data-field'); 7177 var el = ge(cb.getAttribute('data-id')); 7178 if(!fld || !el) return; 7179 var raw = String(el.value).trim(); 7180 if(raw === '') return; 7181 var isPct = (fld === 'listCommissionPct' || fld === 'buyerCommissionPct'); 7182 var newVal = isPct ? normPct(raw) : raw; 7183 var oldVal = tx[fld]; 7184 if(String(oldVal == null ? '' : oldVal) === String(newVal == null ? '' : newVal)) return; 7185 tx[fld] = newVal; 7186 _applied++; 7187 var chRow = { 7188 id: Date.now() + Math.floor(Math.random()*100000) + _applied, 7189 transactionId: tx.id, 7190 contactId: contactId, 7191 field: fld, 7192 oldValue: (oldVal == null ? null : String(oldVal)), 7193 newValue: (newVal == null ? null : String(newVal)), 7194 docType: r.docType || 'Document', 7195 addendumNo: accNo, 7196 effectiveDate: accDate || null, 7197 documentId: null, 7198 appliedAt: new Date().toISOString() 7199 }; 7200 CH.push(chRow); saveCH(chRow); 7201 }); 7202 // Track the highest addendum applied so later scans can detect out-of-order documents. 7203 if(accNo != null){ 7204 var prevNo = (tx.lastAddendumNo == null ? null : parseFloat(tx.lastAddendumNo)); 7205 if(prevNo == null || isNaN(prevNo) || accNo > prevNo){ 7206 tx.lastAddendumNo = accNo; 7207 if(accDate) tx.lastAddendumDate = accDate; 7208 } 7209 } 7210 // Fill in a blank address from the scanned doc - never overwrite one that's already set. 7211 if(!tx.address && address) tx.address = address; 7212 } else if(isLease){ 7213 // ---- New commercial lease transaction ---- 7214 var Ld = readLeaseForm(r); 7215 if(!String(Ld.premises||address||'').trim()){ alert('This transaction needs a property address before it can be created. Enter it in the âProperty Addressâ field above (from the scan or typed in).'); if(btn){ btn.disabled=false; } return; } 7216 var leaseSide = (ge('sc_client_side') && ge('sc_client_side').value === 'seller') ? 'landlord' : 'tenant'; 7217 tx = { 7218 id: Date.now() + Math.floor(Math.random()*100000), 7219 contactId: contactId, 7220 type: leaseSide, 7221 category: 'commercial_lease', 7222 address: Ld.premises || address, 7223 status: 'active', 7224 steps: {}, 7225 notes: 'Imported from lease scan: ' + (r.docType||'Commercial Lease') + '. ' + (r.summary||''), 7226 details: Ld 7227 }; 7228 TX.push(tx); 7229 } else { 7230 if(!String(address||'').trim()){ alert('This transaction needs a property address before it can be created. Enter it in the âProperty Addressâ field above (from the scan or typed in).'); if(btn){ btn.disabled=false; } return; } 7231 var txType = (ge('sc_client_side') && ge('sc_client_side').value) ? ge('sc_client_side').value : ((r.docType && r.docType.toLowerCase().indexOf('list') >= 0) ? 'seller' : 'buyer'); 7232 tx = { 7233 id: Date.now() + Math.floor(Math.random()*100000), 7234 contactId: contactId, 7235 type: txType, 7236 address: address, 7237 price: price, 7238 mlsNum: mlsNum, 7239 lender: lender, 7240 titleCo: titleCo, 7241 notes: 'Imported from document scan: ' + (r.docType||'Document') + '. ' + (r.summary||''), 7242 status: 'active', 7243 steps: {}, 7244 contractDate: contractDate, 7245 closingDate: closingDate, 7246 earnestDate: earnestDate, 7247 dueDiligDate: dueDiligDate, 7248 financingDate: financingDate, 7249 appraisalDate: appraisalDate, 7250 listCommissionPct: normPct(listCommPct), 7251 buyerCommissionPct: normPct(buyerCommPct), 7252 listCommissionAmt: null, 7253 buyerCommissionAmt: null 7254 }; 7255 TX.push(tx); 7256 } 7257 7258 // ---- Financing type + seller-financing note terms (residential purchases only) ---- 7259 if(!isLease){ 7260 var _fin = r.financing || {}; 7261 // Determine financing from several independent signals so one mis-read can't flip it: 7262 // 1. a Seller Financing Addendum checked in Section 9 (the scan reads this section reliably), 7263 // 2. the deterministic classification of the transcribed 2.1 payment lines, 7264 // 3. the model's own financing.type. 7265 // Any seller signal wins (a seller-financing addendum being attached is definitive). 7266 var _lines = (r.repc && r.repc.paymentLines) || _fin.paymentLines; 7267 var _cls = classifyFinancingFromLines(_lines); 7268 var _sellerAddendum = !!(r.repc && Array.isArray(r.repc.addendaTypes) && r.repc.addendaTypes.indexOf('seller_financing') >= 0); 7269 var _modelType = normFinancingType(_fin.type); 7270 var _ft; 7271 if(_sellerAddendum || (_cls && _cls.type === 'seller') || _modelType === 'seller') _ft = 'seller'; 7272 else if(_cls) _ft = _cls.type; 7273 else _ft = _modelType; 7274 if(_ft) tx.financingType = _ft; 7275 if(_ft === 'seller'){ 7276 var _sf = sfTermsFromScan(_fin) || {}; 7277 // Seed the financed principal from the Seller Financing line if the model didn't fill it. 7278 if(_cls && _cls.sellerAmount && !_sf.amount) _sf.amount = _cls.sellerAmount; 7279 // Fill terms additively: a later scan (e.g. the seller-financing addendum) fills blanks 7280 // without wiping terms an earlier scan already captured. 7281 tx.details = tx.details || {}; 7282 var _cur = tx.details.sf || {}; 7283 Object.keys(_sf).forEach(function(k){ if(_sf[k] !== '' && _sf[k] != null) _cur[k] = _sf[k]; }); 7284 if(Object.keys(_cur).length) tx.details.sf = _cur; 7285 } 7286 // REPC contract facts (included items, possession, contingency waivers, home warranty, etc.) 7287 if(r.repc) mergeREPCFacts(tx, r.repc); 7288 } 7289 7290 // Residential checklist auto-completion doesn't apply to commercial leases (different lifecycle). 7291 if(!isLease){ 7292 // ---- Gap B: auto-check date-tracked checklist steps ---- 7293 var template = TC_TEMPLATES[tx.type] || TC_TEMPLATES['buyer']; 7294 if(!tx.steps) tx.steps = {}; 7295 template.forEach(function(phase){ 7296 phase.steps.forEach(function(step){ 7297 // Tracking steps auto-check when their date is present. A COMPLETION step (Appraisal) is a 7298 // real to-do the user checks off when done, so never auto-check it. 7299 if(step.hasDate && step.dateField && !step.completion && tx[step.dateField] && !tx.steps[step.key]){ 7300 tx.steps[step.key] = true; 7301 } 7302 }); 7303 }); 7304 // ---- A REPC means the deal is under contract, so the pre-contract work is done. Mark the 7305 // first two phases (intake + search/go-live) complete and the "offer submitted" step. The 7306 // date-driven Phase 3 steps (due diligence, financing, appraisal, earnest) are handled above - 7307 // checked only when their date is present, left unchecked when it isn't. Everything else stays 7308 // unchecked. Only ADDS checks; never unchecks anything you may have set by hand. 7309 var scIsREPC = r.docType && r.docType.toLowerCase().indexOf('repc') >= 0; 7310 if(scIsREPC){ 7311 template.forEach(function(phase, pi){ 7312 if(pi <= 1){ 7313 phase.steps.forEach(function(step){ tx.steps[step.key] = true; }); // Phase 1 & 2: all done 7314 } else if(pi === 2){ 7315 phase.steps.forEach(function(step){ if(/_offer$/.test(step.key)) tx.steps[step.key] = true; }); // offer submitted 7316 } 7317 }); 7318 } 7319 // ---- Scanning a document is proof its checklist item is done. Map the doc type to the 7320 // step key it satisfies (per side). Only exact 1:1 matches, so nothing is checked early. ---- 7321 var SCAN_DOC_STEPS = { 7322 'title commitment': { buyer:'b3_title', seller:'s3_title' }, 7323 'settlement statement': { buyer:'b4_settlement', seller:'s4_settlement' }, 7324 'appraisal': { buyer:'b3_appr', seller:'s3_appr' } 7325 }; 7326 var _scDt = String(r.docType||'').toLowerCase().trim(); 7327 var _scMap = SCAN_DOC_STEPS[_scDt]; 7328 if(_scMap){ 7329 var _scKey = _scMap[tx.type] || _scMap.buyer;
7330 if(_scKey) tx.steps[_scKey] = true; 7331 } 7332 } 7333 saveTX(tx); 7334 7335 // ---- Auto-advance contact to "Under Contract" (a scanned purchase means under contract; a 7336 // scanned lease means an active, executed deal). Skip if already Under Contract or Closed. 7337 var _ucC = gc(contactId); 7338 if((isLease || tx.contractDate) && _ucC && _ucC.stage !== 'Under Contract' && _ucC.stage !== 'Closed'){ 7339 ss(contactId, 'Under Contract'); 7340 } 7341 7342 // ---- Deadlines / milestones (tagged with transactionId) ---- 7343 // Lease deals track lease milestones; purchases track the residential contract deadlines. 7344 var dlMap = isLease ? [ 7345 {type:'Lease Commencement', val:(tx.details&&tx.details.commencementDate)||''}, 7346 {type:'Rent Commencement', val:(tx.details&&tx.details.rentCommencementDate)||''}, 7347 {type:'Lease Expiration', val:(tx.details&&tx.details.expirationDate)||''}, 7348 {type:'Renewal Option Deadline', val:(tx.details&&tx.details.renewalOptionDeadline)||''} 7349 ] : [ 7350 {type:'Earnest Money Due', val:tx.earnestDate}, 7351 {type:'Due Diligence Deadline', val:tx.dueDiligDate}, 7352 {type:'Financing Deadline', val:tx.financingDate}, 7353 {type:'Appraisal Deadline', val:tx.appraisalDate}, 7354 {type:'Closing Date', val:tx.closingDate} 7355 ]; 7356 dlMap.forEach(function(dm){ 7357 if(!dm.val) return; 7358 var ex = D.find(function(d){ return String(d.transactionId)===String(tx.id) && d.type===dm.type; }) 7359 || D.find(function(d){ return d.transactionId==null && String(d.contactId)===String(contactId) && d.type===dm.type; }); 7360 if(ex){ ex.date = dm.val; ex.transactionId = tx.id; saveDL(ex); } 7361 else { var nd={id:Date.now()+Math.floor(Math.random()*100000), contactId:contactId, transactionId:tx.id, type:dm.type, date:dm.val}; D.push(nd); saveDL(nd); } 7362 }); 7363 7364 // ---- Note ---- 7365 // Persist BOTH languages: the English summary as the note body, and the AI's Spanish 7366 // summary in textEs so the Notes tab can offer an EN/ES toggle. Categorized as Document. 7367 var noteText = 'Document scanned: ' + (r.docType||'Document') + '. ' + (r.summary||''); 7368 var noteEs = r.spanishSummary ? ('Documento escaneado: ' + (r.docType||'Documento') + '. ' + r.spanishSummary) : ''; 7369 // Tag with the transaction so deleting the transaction also removes this note (cascade). 7370 var newNote = {id:Date.now()+Math.floor(Math.random()*100000), contactId:contactId, transactionId:tx.id, text:noteText, date:new Date().toISOString(), category:'Document', textEs:noteEs}; 7371 N.push(newNote); saveNote(newNote); logActivity(contactId,'Document scanned'); 7372 7373 r.imported = true; 7374 renderScannerHistory(); rd(); renderTC(); 7375 7376 // ---- Store the file (warn-and-continue on failure) ---- 7377 var fileWarn = ''; 7378 if(lastScanFile){ 7379 if(btn){ btn.textContent='Storing file...'; btn.disabled=true; } 7380 try{ 7381 await saveDocument(lastScanFile, { 7382 contact_id: contactId, 7383 transaction_id: tx.id, 7384 doc_type: r.docType || 'Document', 7385 summary: r.summary || '', 7386 extracted: buildDocExtract(r) 7387 }); 7388 }catch(e){ 7389 fileWarn = ' (Note: the original file was not stored - ' + e.message + '. You can re-scan to store it.)'; 7390 } 7391 } else { 7392 fileWarn = ' (Note: original file no longer in memory, not stored.)'; 7393 } 7394 7395 // ---- Result state, stay on scanner (option b) ---- 7396 var rs = ge('sc_result_state'); 7397 if(rs){ 7398 rs.style.display='block'; 7399 rs.innerHTML=''; 7400 var msg=document.createElement('div'); 7401 msg.style.cssText='font-size:14px;color:var(--text);margin-bottom:8px;'; 7402 var who=gc(contactId); 7403 msg.textContent = 'Imported. ' + (choice.indexOf('existing:')===0?'Updated':'Created') + ' transaction for ' + (who?fn(who):'contact') + '.' + fileWarn; 7404 rs.appendChild(msg); 7405 var viewBtn=document.createElement('button'); 7406 viewBtn.className='btn btn-p'; viewBtn.style.fontSize='13px'; viewBtn.textContent='View Transaction'; 7407 viewBtn.addEventListener('click', function(){ sp('tc'); if(typeof openTCDetail==='function') openTCDetail(tx.id); }); 7408 rs.appendChild(viewBtn); 7409 } 7410 if(btn){ btn.textContent='Imported'; btn.disabled=true; } 7411} 7412 7413function showScannerRawResult(text){ 7414 // One final attempt to parse JSON from anywhere in the text 7415 try{ 7416 var fb = text.indexOf('{'); 7417 var lb = text.lastIndexOf('}'); 7418 if(fb !== -1 && lb > fb){ 7419 var attempt = JSON.parse(text.substring(fb, lb+1)); 7420 if(attempt && attempt.docType){ showScannerResults(attempt); return; } 7421 } 7422 }catch(e2){} 7423 7424 ge('scannerProcessing').style.display = 'none'; 7425 ge('scannerHistory').style.display = 'block'; 7426 var res = ge('scannerResults'); 7427 res.style.display = 'block'; 7428 res.innerHTML = ''; 7429 var card = document.createElement('div'); 7430 card.className = 'scanner-result'; 7431 var title = document.createElement('div'); 7432 title.className = 'scanner-result-title'; 7433 title.textContent = 'Document Analysis'; 7434 card.appendChild(title); 7435 var sum = document.createElement('div'); 7436 sum.className = 'scanner-summary'; 7437 sum.style.marginTop = '12px'; 7438 sum.textContent = text; 7439 card.appendChild(sum); 7440 7441 // Store original document (works even when extraction did not parse) 7442 var saveDocDiv2 = document.createElement('div'); 7443 saveDocDiv2.style.cssText = 'margin-top:14px;padding-top:12px;border-top:1px solid var(--border);'; 7444 var saveDocLabel2 = document.createElement('div'); 7445 saveDocLabel2.style.cssText = 'font-size:18px;color:var(--text3);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:8px;'; 7446 saveDocLabel2.textContent = 'Store Original Document'; 7447 saveDocDiv2.appendChild(saveDocLabel2); 7448 7449 var docContactWrap2 = document.createElement('div'); 7450 docContactWrap2.style.marginBottom = '8px'; 7451 saveDocDiv2.appendChild(docContactWrap2); 7452 buildContactPicker(docContactWrap2, 'sc_doc_contact', 'Link to contact (search name, email, phone)...'); 7453 7454 var docTxSel2 = document.createElement('select'); 7455 docTxSel2.id = 'sc_doc_tx'; 7456 docTxSel2.className = 'fsel'; 7457 docTxSel2.style.marginBottom = '8px'; 7458 var dtBlank2 = document.createElement('option'); 7459 dtBlank2.value = ''; dtBlank2.textContent = '-- Link to transaction (optional) --'; 7460 docTxSel2.appendChild(dtBlank2); 7461 TX.filter(function(tx){ return tx.status!=='closed'; }
7461).forEach(function(tx){ 7462 var c4 = gc(tx.contactId); 7463 var o = document.createElement('option'); 7464 o.value = tx.id; o.textContent = (tx.address||'Unknown') + (c4 ? ' - ' + fn(c4) : ''); 7465 docTxSel2.appendChild(o); 7466 }); 7467 saveDocDiv2.appendChild(docTxSel2); 7468 7469 var saveDocBtn2 = document.createElement('button'); 7470 saveDocBtn2.className = 'btn btn-p'; 7471 saveDocBtn2.textContent = 'Save Document'; 7472 saveDocBtn2.addEventListener('click', function(){ saveScanDocument({docType:'Document', summary:text}, saveDocBtn2); }); 7473 saveDocDiv2.appendChild(saveDocBtn2); 7474 card.appendChild(saveDocDiv2); 7475 var copyBtn = document.createElement('button'); 7476 copyBtn.className = 'btn btn-g'; 7477 copyBtn.style.marginTop = '12px'; 7478 copyBtn.textContent = 'Copy Text'; 7479 copyBtn.addEventListener('click', function(){ navigator.clipboard && navigator.clipboard.writeText(text); copyBtn.textContent='Copied!'; setTimeout(function(){ copyBtn.textContent='Copy Text'; },2000); }); 7480 var newScanBtn = document.createElement('button'); 7481 newScanBtn.className = 'btn btn-p'; 7482 newScanBtn.style.cssText = 'margin-top:12px;margin-left:8px;'; 7483 newScanBtn.textContent = 'Scan Another'; 7484 newScanBtn.addEventListener('click', function(){ ge('scannerUploadArea').style.display='block'; ge('scannerResults').style.display='none'; ge('scannerFileInput').value=''; }); 7485 card.appendChild(copyBtn); card.appendChild(newScanBtn); 7486 res.appendChild(card); 7487} 7488 7489function showScannerError(msg){ 7490 ge('scannerProcessing').style.display = 'none'; 7491 ge('scannerUploadArea').style.display = 'block'; 7492 ge('scannerHistory').style.display = 'block'; 7493 alert('Scanner error: ' + msg); 7494} 7495 7496// -- GOOGLE CALENDAR SYNC --- 7497async function syncToGCal(eventData, memberId){ 7498 // Find which member to sync to 7499 var mid = memberId; 7500 7501 // If no member specified, ask which account 7502 if(!mid){ 7503 var connected = Object.keys(gmailState.connectedAccounts); 7504 if(!connected.length){ 7505 alert('No Gmail/Calendar connected. Go to Gmail tab and connect your account first.'); 7506 return; 7507 } 7508 if(connected.length === 1){ 7509 mid = connected[0]; 7510 } else { 7511 // Show picker 7512 var names = connected.map(function(id){ 7513 var m = TM.find(function(x){ return String(x.id)===String(id); }); 7514 var acc = gmailState.connectedAccounts[id]; 7515 return (m ? m.first+' '+m.last : 'Member '+id) + ' (' + (acc?acc.email:'') + ')'; 7516 }); 7517 var NL4='\n'; 7518 var choice = connected[parseInt(prompt('Add to which calendar?' + NL4 + names.map(function(n,i){ return (i+1)+'. '+n; }).join(NL4) + NL4+NL4+'Enter number:'))-1]; 7519 if(!choice) return; 7520 mid = choice; 7521 } 7522 } 7523 7524 // Check account is connected 7525 if(!gmailState.connectedAccounts[String(mid)]){ 7526 // Try to find any connected account 7527 var anyConnected = Object.keys(gmailState.connectedAccounts)[0]; 7528 if(!anyConnected){ 7529 alert('Please connect Gmail first. Go to the Gmail tab and click Connect Gmail.'); 7530 return; 7531 } 7532 mid = anyConnected; 7533 } 7534 7535 var btn = event && event.target; 7536 var origText = btn ? btn.textContent : ''; 7537 if(btn){ btn.textContent = 'Adding...'; btn.disabled = true; } 7538 7539 try{ 7540 var resp = await postApi('/api/gcal', {action:'create', memberId:mid, event:eventData}); 7541 var respText = await resp.text(); 7542 var data; 7543 try{ data = JSON.parse(respText); } 7544 catch(e){ alert('Calendar sync error: ' + respText.substring(0,100)); return; } 7545 7546 if(data.error){ 7547 // If scope error, need to reconnect 7548 if(data.error.includes('scope') || data.error.includes('permission') || data.error.includes('Calendar')){ 7549 alert('Calendar access not authorized. Please disconnect and reconnect Gmail to grant calendar permissions.'); 7550 } else { 7551 alert('Calendar sync error: ' + data.error); 7552 } 7553 return; 7554 } 7555 7556 if(btn){ btn.textContent = 'Added!'; btn.style.background='rgba(107,201,122,0.2)'; btn.style.color='var(--lead)'; btn.style.borderColor='var(--lead)'; } 7557 setTimeout(function(){ if(btn){ btn.textContent=origText; btn.disabled=false; btn.style.background=''; btn.style.color=''; btn.style.borderColor=''; } }, 3000); 7558 7559 return { gcalId: data.gcalId, mid: mid }; // so the caller can remember it for delete-sync 7560 7561 }catch(e){ 7562 alert('Sync error: ' + e.message); 7563 if(btn){ btn.textContent=origText; btn.disabled=false; } 7564 } 7565} 7566// Local map: CRM event id -> {gcalId, mid}. Kept in localStorage (not the synced event object) so 7567// we can delete the matching Google Calendar event without touching the cal_events table schema. 7568var _gcalMap = {}; 7569function loadGcalMap(){ try{ _gcalMap = JSON.parse(localStorage.getItem('crm_gcal_map')||'{}') || {}; }catch(e){ _gcalMap = {}; } } 7570function saveGcalMap(){ try{ localStorage.setItem('crm_gcal_map', JSON.stringify(_gcalMap)); }catch(e){} } 7571loadGcalMap(); 7572 7573function syncEventToGCal(ev){ 7574 // Convert CRM event to syncable format 7575 var c = ev.data && ev.data.contactId ? gc(ev.data.contactId) : null; 7576 var eventData = { 7577 title: ev.label, 7578 date: ev.data.date || (ev.data.date), 7579 time: ev.time || (ev.data && ev.data.time) || '', 7580 endTime: ev.data && ev.data.endTime || '', 7581 type: ev.type.replace('ev-',''), 7582 clientName: c ? fn(c) : '', 7583 address: ev.data && ev.data.address || '', 7584 notes: ev.data && ev.data.notes || '' 7585 }; 7586 syncToGCal(eventData, ev.memberId || (ev.data && ev.data.memberId) || null); 7587} 7588 7589// -- CALENDAR --- 7590var CAL = { 7591 view: 'agenda', 7592 year: new Date().getFullYear(), 7593 month: new Date().getMonth(), 7594 selectedDate: null, 7595 filterMembers: {}, // memberId -> true/false 7596 events: [], // custom events: [{id,title,date,time,endTime,type,memberId,contactId,notes}] 7597 gcalEvents: [], // events fetched from Google Calendar 7598 gcalLoading: false, 7599 gcalLastFetch: 0 7600}; 7601
7602function initCalendar(){ 7603 // Load custom events 7604 try{ 7605 var saved = localStorage.getItem('crm_cal_events'); 7606 if(saved) CAL.events = JSON.parse(saved); 7607 }catch(e){} 7608 7609 // Load cached GCal events 7610 try{ 7611 var gcalSaved = localStorage.getItem('crm_gcal_events'); 7612 if(gcalSaved) CAL.gcalEvents = JSON.parse(gcalSaved); 7613 }catch(e){} 7614 7615 // Init member filters - all on by default 7616 TM.forEach(function(m){ CAL.filterMembers[m.id] = true; }); 7617 7618 renderCalMemberFilters(); 7619 renderCalendar(); 7620 7621 // Fetch Google Calendar events in background 7622 fetchGCalEvents().catch(function(e){ console.log('GCal fetch error:', e); }); 7623} 7624 7625async function fetchGCalEvents(){ 7626 if(CAL.gcalLoading) return; 7627 // Only refetch if more than 5 mins old 7628 if(Date.now() - CAL.gcalLastFetch < 300000 && CAL.gcalEvents.length) return; 7629 7630 var connected = Object.keys(gmailState.connectedAccounts); 7631 if(!connected.length){ 7632 // Try loading from Supabase directly 7633 if(!supaReady) return; 7634 try{ 7635 var resp = await fetch(SUPA_URL + '/rest/v1/gmail_tokens?select=member_id,email', { 7636 headers: await getAuthHeaders() 7637 }); 7638 var tokens = await resp.json(); 7639 if(Array.isArray(tokens)) tokens.forEach(function(t){ 7640 connected.push(String(t.member_id)); 7641 }); 7642 }catch(e){ return; } 7643 } 7644 if(!connected.length) return; 7645 7646 CAL.gcalLoading = true; 7647 var allEvents = []; 7648 var gcalErrors = []; 7649 7650 // Fetch from each connected account 7651 await Promise.all(connected.map(async function(mid){ 7652 try{ 7653 var resp = await postApi('/api/gcal', {action:'list', memberId:mid}); 7654 var text = await resp.text(); 7655 var data;
7656 try{ data = JSON.parse(text); }catch(e){ 7657 gcalErrors.push('Parse error ('+mid+'): '+text.substring(0,100)); 7658 return; 7659 } 7660 if(data.error){ 7661 gcalErrors.push('API error ('+mid+'): '+data.error); 7662 return; 7663 } 7664 if(data.events){ 7665 data.events.forEach(function(ev){ 7666 ev.memberId = mid; 7667 ev.fromGCal = true; 7668 ev.gcalMemberEmail = gmailState.connectedAccounts[String(mid)] ? gmailState.connectedAccounts[String(mid)].email : ''; 7669 allEvents.push(ev); 7670 }); 7671 } 7672 }catch(e){ gcalErrors.push('Fetch error ('+mid+'): '+e.message); } 7673 })); 7674 7675 if(gcalErrors.length){ 7676 console.log('GCal errors:', gcalErrors); 7677 // Show error in refresh button 7678 var refreshBtn = ge('btnRefreshGCal'); 7679 if(refreshBtn){ 7680 refreshBtn.textContent = 'GCal Error - click for details'; 7681 refreshBtn.style.color = 'var(--danger)'; 7682 refreshBtn.title = gcalErrors.join(' | '); 7683 } 7684 } 7685 7686 CAL.gcalEvents = allEvents; 7687 CAL.gcalLastFetch = Date.now(); 7688 CAL.gcalLoading = false; 7689 7690 // Cache locally 7691 try{ localStorage.setItem('crm_gcal_events', JSON.stringify(allEvents)); }catch(e){} 7692 7693 // Re-render with new events 7694 renderCalendar(); 7695} 7696 7697function saveCalEvents(){ 7698 try{ localStorage.setItem('crm_cal_events', JSON.stringify(CAL.events)); }catch(e){} 7699 if(supaReady) dbSave('cal_events', CAL.events); 7700} 7701 7702function renderCalMemberFilters(){ 7703 var el = ge('calMemberFilters'); 7704 if(!el) return; 7705 el.innerHTML = ''; 7706 // All button 7707 var allBtn = document.createElement('button'); 7708 allBtn.className = 'cal-member-pill active'; 7709 allBtn.style.background = 'var(--surface2)'; 7710 allBtn.style.color = 'var(--text2)'; 7711 allBtn.textContent = 'All'; 7712 allBtn.addEventListener('click', function(){ 7713 TM.forEach(function(m){ CAL.filterMembers[m.id] = true; }); 7714 renderCalMemberFilters(); 7715 renderCalendar(); 7716 }); 7717 el.appendChild(allBtn); 7718 7719 TM.forEach(function(m){ 7720 var pill = document.createElement('button'); 7721 pill.className = 'cal-member-pill' + (CAL.filterMembers[m.id] ? ' active' : ''); 7722 pill.style.background = (m.color||'#C9A84C') + '33'; 7723 pill.style.color = m.color||'#C9A84C'; 7724 pill.textContent = m.first; 7725 (function(mid){ pill.addEventListener('click', function(){ 7726 CAL.filterMembers[mid] = !CAL.filterMembers[mid]; 7727 renderCalMemberFilters(); 7728 renderCalendar(); 7729 }); })(m.id); 7730 el.appendChild(pill); 7731 }); 7732} 7733 7734// Format a 24h "HH:MM" time string as 12h with AM/PM (e.g. "14:30" -> "2:30 PM"). 7735function fmtTime12(t){ 7736 if(!t || String(t).indexOf(':') < 0) return t || ''; 7737 var p = String(t).split(':'); 7738 var h = parseInt(p[0], 10); 7739 var m = (p[1] || '00').slice(0,2); 7740 if(isNaN(h)) return t; 7741 var ap = h < 12 ? 'AM' : 'PM'; 7742 var h12 = h % 12; if(h12 === 0) h12 = 12; 7743 return h12 + ':' + m + ' ' + ap; 7744} 7745 7746// Get all events for a given date string YYYY-MM-DD 7747function getEventsForDate(dateStr){ 7748 var events = []; 7749 7750 // Deadlines 7751 D.forEach(function(d){ 7752 if(d.date === dateStr){ 7753 var c = gc(d.contactId); 7754 var n = du(d.date); 7755 events.push({ 7756 type: n < 0 ? 'ev-overdue' : 'ev-deadline', 7757 label: d.type + (c ? ' - ' + fn(c) : ''), 7758 time: '', 7759 data: d, 7760 dataType: 'deadline', 7761 memberId: d.assignedTo || null, 7762 color: n < 0 ? '#ff6666' : '#ff9999' 7763 }); 7764 } 7765 }); 7766 7767 // Follow-ups 7768 F.forEach(function(f){ 7769 if(f.date === dateStr && !f.done){ 7770 var c = gc(f.contactId); 7771 events.push({ 7772 type: 'ev-followup', 7773 label: f.label + (c ? ' - ' + fn(c) : ''), 7774 time: '', 7775 data: f, 7776 dataType: 'followup', 7777 memberId: f.assignedTo || null, 7778 color: '#6bc97a' 7779 }); 7780 } 7781 }); 7782 7783 // Transaction closing dates 7784 TX.forEach(function(tx){ 7785 if(tx.closingDate === dateStr){ 7786 var c = gc(tx.contactId); 7787 events.push({ 7788 type: 'ev-transaction', 7789 label: 'CLOSE: ' + (tx.address || (c ? fn(c) : 'Unknown')), 7790 time: '', 7791 data: tx, 7792 dataType: 'transaction', 7793 memberId: tx.assignedTo || null, 7794 color: '#c9a84c' 7795 }); 7796 } 7797 // Also show earnest, due diligence etc 7798 var txDates = [ 7799 {key:'earnestDate', label:'Earnest Money'}, 7800 {key:'dueDiligDate', label:'Due Diligence'}, 7801 {key:'financingDate', label:'Financing'}, 7802 {key:'appraisalDate', label:'Appraisal'} 7803 ];
7804 txDates.forEach(function(td){ 7805 if(tx[td.key] === dateStr){ 7806 var c2 = gc(tx.contactId); 7807 events.push({ 7808 type: 'ev-deadline', 7809 label: td.label + ': ' + (tx.address || (c2 ? fn(c2) : '')), 7810 time: '', 7811 data: tx, 7812 dataType: 'txdate', 7813 txDateKey: td.key, 7814 memberId: tx.assignedTo || null, 7815 color: '#ff9999' 7816 }); 7817 } 7818 }); 7819 }); 7820 7821 // Custom events 7822 CAL.events.forEach(function(ev){ 7823 if(ev.date === dateStr){ 7824 events.push({ 7825 type: 'ev-custom', 7826 label: ev.title, 7827 time: ev.time || '', 7828 data: ev, 7829 dataType: 'custom', 7830 memberId: ev.memberId || null, 7831 color: '#7ab8f5' 7832 }); 7833 } 7834 }); 7835 7836 // Google Calendar events. Skip any that are copies of CRM events we synced out (matched by the 7837 // Google event id we recorded), so a CRM event doesn't also appear as a read-only Google duplicate. 7838 var _mineG = {}; 7839 try{ Object.keys(_gcalMap||{}).forEach(function(k){ var g=_gcalMap[k]; if(g && g.gcalId) _mineG[g.gcalId] = true; }); }catch(e){} 7840 CAL.gcalEvents.forEach(function(ev){ 7841 if(ev.date === dateStr && !_mineG[ev.gcalId]){ 7842 events.push({ 7843 type: 'ev-gcal', 7844 label: ev.title, 7845 time: ev.time || '', 7846 data: ev, 7847 dataType: 'gcal', 7848 memberId: ev.memberId || null, 7849 color: '#a78bfa', 7850 gcalLink: ev.link 7851 }); 7852 } 7853 }); 7854 7855 // Filter by member 7856 var anyActive = Object.values(CAL.filterMembers).some(function(v){ return v; }); 7857 if(anyActive){ 7858 events = events.filter(function(ev){ 7859 if(!ev.memberId) return true; 7860 return CAL.filterMembers[ev.memberId]; 7861 }); 7862 } 7863 7864 // Sort by time 7865 events.sort(function(a,b){ 7866 if(!a.time && !b.time) return 0; 7867 if(!a.time) return 1; 7868 if(!b.time) return -1; 7869 return a.time.localeCompare(b.time); 7870 }); 7871 7872 return events; 7873} 7874 7875function renderCalendar(){ 7876 var months = ['January','February','March','April','May','June','July','August','September','October','November','December']; 7877 var days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; 7878 7879 ge('calTitle').textContent = months[CAL.month] + ' ' + CAL.year; 7880 7881 if(CAL.view === 'month') renderMonthView(days); 7882 else if(CAL.view === 'week') renderWeekView(days); 7883 else renderAgendaView(); 7884} 7885 7886function renderMonthView(days){ 7887 var body = ge('calBody'); 7888 body.innerHTML = ''; 7889 7890 var grid = document.createElement('div'); 7891 grid.className = 'cal-grid'; 7892 7893 // Day of week headers 7894 days.forEach(function(d){ 7895 var hdr = document.createElement('div'); 7896 hdr.className = 'cal-dow'; 7897 hdr.textContent = d; 7898 grid.appendChild(hdr); 7899 }); 7900 7901 // Get first day of month 7902 var firstDay = new Date(CAL.year, CAL.month, 1).getDay(); 7903 var daysInMonth = new Date(CAL.year, CAL.month + 1, 0).getDate(); 7904 var daysInPrevMonth = new Date(CAL.year, CAL.month, 0).getDate(); 7905 var today = tod(); 7906 7907 // Fill grid 7908 var totalCells = Math.ceil((firstDay + daysInMonth) / 7) * 7; 7909 for(var i = 0; i < totalCells; i++){ 7910 var cell = document.createElement('div'); 7911 cell.className = 'cal-cell'; 7912 7913 var dayNum, month, year, isOther = false; 7914 if(i < firstDay){ 7915 dayNum = daysInPrevMonth - firstDay + i + 1; 7916 month = CAL.month - 1; 7917 year = CAL.year; 7918 if(month < 0){ month = 11; year--; } 7919 isOther = true; 7920 } else if(i >= firstDay + daysInMonth){ 7921 dayNum = i - firstDay - daysInMonth + 1; 7922 month = CAL.month + 1; 7923 year = CAL.year; 7924 if(month > 11){ month = 0; year++; } 7925 isOther = true; 7926 } else { 7927 dayNum = i - firstDay + 1; 7928 month = CAL.month; 7929 year = CAL.year; 7930 } 7931 7932 var dateStr = year + '-' + String(month+1).padStart(2,'0') + '-' + String(dayNum).padStart(2,'0'); 7933 if(isOther) cell.classList.add('other-month'); 7934 if(dateStr === today) cell.classList.add('today'); 7935 if(dateStr === CAL.selectedDate) cell.classList.add('selected'); 7936 7937 var num = document.createElement('div'); 7938 num.className = 'cal-day-num'; 7939 num.textContent = dayNum; 7940 cell.appendChild(num); 7941 7942 // Add events 7943 var evs = getEventsForDate(dateStr); 7944 var maxShow = window.innerWidth <= 768 ? 1 : 3;
7945 evs.slice(0, maxShow).forEach(function(ev){ 7946 var evEl = document.createElement('div'); 7947 evEl.className = 'cal-event ' + ev.type; 7948 evEl.textContent = (ev.time ? fmtTime12(ev.time) + ' ' : '') + ev.label; 7949 evEl.title = ev.label; 7950 (function(e){ evEl.addEventListener('click', function(evt){ 7951 evt.stopPropagation(); 7952 openCalEventDetail(e); 7953 }); })(ev); 7954 cell.appendChild(evEl); 7955 }); 7956 if(evs.length > maxShow){ 7957 var more = document.createElement('div'); 7958 more.style.cssText = 'font-size:12px;color:var(--text3);padding:1px 4px;'; 7959 more.textContent = '+' + (evs.length - maxShow) + ' more'; 7960 cell.appendChild(more); 7961 } 7962 7963 (function(ds){ cell.addEventListener('click', function(){ 7964 CAL.selectedDate = ds; 7965 ge('calEvDate').value = ds; 7966 renderCalendar(); 7967 }); })(dateStr); 7968 7969 grid.appendChild(cell); 7970 } 7971 7972 body.appendChild(grid); 7973} 7974 7975function renderWeekView(days){ 7976 var body = ge('calBody'); 7977 body.innerHTML = ''; 7978 7979 // Find start of week (Sunday) 7980 var today = new Date(CAL.year, CAL.month, 1); 7981 if(CAL.selectedDate){ 7982 var parts = CAL.selectedDate.split('-'); 7983 today = new Date(parseInt(parts[0]), parseInt(parts[1])-1, parseInt(parts[2])); 7984 } 7985 var startOfWeek = new Date(today); 7986 startOfWeek.setDate(today.getDate() - today.getDay()); 7987 7988 var grid = document.createElement('div'); 7989 grid.className = 'cal-week-grid'; 7990 7991 // Header row 7992 var emptyHdr = document.createElement('div'); 7993 emptyHdr.className = 'cal-week-hour'; 7994 emptyHdr.style.cssText += 'font-size:11px;'; 7995 grid.appendChild(emptyHdr); 7996 7997 var todayStr = tod(); 7998 for(var d = 0; d < 7; d++){ 7999 var dt = new Date(startOfWeek); 8000 dt.setDate(startOfWeek.getDate() + d); 8001 var ds = dt.getFullYear() + '-' + String(dt.getMonth()+1).padStart(2,'0') + '-' + String(dt.getDate()).padStart(2,'0'); 8002 var hdr = document.createElement('div'); 8003 hdr.className = 'cal-week-hour'; 8004 hdr.style.textAlign = 'center'; 8005 hdr.style.color = ds === todayStr ? 'var(--accent)' : 'var(--text2)'; 8006 hdr.style.fontWeight = ds === todayStr ? '700' : '400'; 8007 hdr.innerHTML = days[d] + '<br><span style="font-size:16px;font-weight:700;">' + dt.getDate() + '</span>'; 8008 grid.appendChild(hdr); 8009 } 8010 8011 // Hour rows 8012 for(var h = 7; h <= 20; h++){ 8013 var hourLbl = document.createElement('div'); 8014 hourLbl.className = 'cal-week-hour'; 8015 hourLbl.textContent = h <= 12 ? h + 'am' : (h-12) + 'pm'; 8016 if(h === 12) hourLbl.textContent = '12pm'; 8017 grid.appendChild(hourLbl); 8018 8019 for(var d2 = 0; d2 < 7; d2++){ 8020 var dt2 = new Date(startOfWeek); 8021 dt2.setDate(startOfWeek.getDate() + d2); 8022 var ds2 = dt2.getFullYear() + '-' + String(dt2.getMonth()+1).padStart(2,'0') + '-' + String(dt2.getDate()).padStart(2,'0'); 8023 var cell = document.createElement('div'); 8024 cell.className = 'cal-week-cell'; 8025 if(ds2 === todayStr) cell.style.background = 'rgba(201,168,76,0.04)'; 8026 8027 // Show events at this hour 8028 var evs2 = getEventsForDate(ds2).filter(function(ev){ 8029 if(!ev.time) return h === 8; // no-time events at 8am 8030 var evH = parseInt(ev.time.split(':')[0]); 8031 return evH === h; 8032 }); 8033 evs2.forEach(function(ev){ 8034 var evEl = document.createElement('div'); 8035 evEl.className = 'cal-event ' + ev.type; 8036 evEl.style.cssText += 'font-size:12px;'; 8037 evEl.textContent = ev.label; 8038 (function(e){ evEl.addEventListener('click', function(evt){ 8039 evt.stopPropagation(); 8040 openCalEventDetail(e); 8041 }); })(ev); 8042 cell.appendChild(evEl); 8043 }); 8044 8045 (function(ds3){ cell.addEventListener('click', function(){ 8046 CAL.selectedDate = ds3; 8047 var hStr = String(h).padStart(2,'0') + ':00'; 8048 ge('calEvDate').value = ds3; 8049 ge('calEvTime').value = hStr; 8050 populateCalModal(); 8051 om('calEventModal'); 8052 }); })(ds2); 8053 8054 grid.appendChild(cell); 8055 } 8056 } 8057 8058 // Update title for week view 8059 var endOfWeek = new Date(startOfWeek); 8060 endOfWeek.setDate(startOfWeek.getDate() + 6); 8061 var months2 = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; 8062 ge('calTitle').textContent = months2[startOfWeek.getMonth()] + ' ' + startOfWeek.getDate() + ' - ' + months2[endOfWeek.getMonth()] + ' ' + endOfWeek.getDate() + ', ' + endOfWeek.getFullYear(); 8063 8064 body.appendChild(grid); 8065} 8066 8067function renderAgendaView(){ 8068 var body = ge('calBody'); 8069 body.innerHTML = ''; 8070 8071 var months3 = ['January','February','March','April','May','June','July','August','September','October','November','December']; 8072 var todayStr = tod(); 8073 8074 // Show 90 days starting from today - no past days in the agenda. 8075 var startDate = new Date(); 8076
8077 var hasAny = false; 8078 for(var i = 0; i < 90; i++){ 8079 var dt = new Date(startDate); 8080 dt.setDate(startDate.getDate() + i); 8081 var ds = dt.getFullYear() + '-' + String(dt.getMonth()+1).padStart(2,'0') + '-' + String(dt.getDate()).padStart(2,'0'); 8082 var evs = getEventsForDate(ds); 8083 if(!evs.length) continue; 8084 hasAny = true; 8085 8086 var dayHdr = document.createElement('div'); 8087 dayHdr.className = 'cal-agenda-date' + (ds === todayStr ? ' today-header' : ''); 8088 var dayNames = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; 8089 dayHdr.textContent = dayNames[dt.getDay()] + ', ' + months3[dt.getMonth()] + ' ' + dt.getDate() + ', ' + dt.getFullYear() + (ds === todayStr ? ' - TODAY' : ''); 8090 body.appendChild(dayHdr); 8091 8092 evs.forEach(function(ev){ 8093 var row = document.createElement('div'); 8094 row.className = 'cal-agenda-item'; 8095 var dot = document.createElement('div'); 8096 dot.className = 'cal-agenda-dot'; 8097 dot.style.background = ev.color || '#c9a84c'; 8098 var time = document.createElement('div'); 8099 time.className = 'cal-agenda-time'; 8100 time.textContent = ev.time ? fmtTime12(ev.time) : 'All day'; 8101 var info = document.createElement('div'); 8102 info.style.flex = '1'; 8103 var title = document.createElement('div'); 8104 title.className = 'cal-agenda-title'; 8105 title.textContent = ev.label; 8106 var sub = document.createElement('div'); 8107 sub.className = 'cal-agenda-sub'; 8108 if(ev.memberId){ 8109 var m = TM.find(function(x){ return x.id == ev.memberId; }); 8110 if(m) sub.textContent = m.first + ' ' + m.last; 8111 } 8112 info.appendChild(title); 8113 if(sub.textContent) info.appendChild(sub); 8114 row.appendChild(dot); row.appendChild(time); row.appendChild(info); 8115 (function(e){ row.addEventListener('click', function(){ openCalEventDetail(e); }); })(ev); 8116 body.appendChild(row); 8117 }); 8118 } 8119 8120 if(!hasAny){ 8121 body.innerHTML = '<div class="empty" style="padding:40px;">No events in the next 90 days. Add deadlines, follow-ups, or transactions to see them here.</div>'; 8122 } 8123} 8124 8125function openCalEventDetail(ev){ 8126 var detOv = ge('calDetOv'); 8127 ge('calDetTitle').textContent = ev.label; 8128 8129 var sub = ''; 8130 if(ev.time) sub += fmtTime12(ev.time) + ' '; 8131 if(ev.memberId){ 8132 var m = TM.find(function(x){ return x.id == ev.memberId; }); 8133 if(m) sub += '- ' + m.first + ' ' + m.last; 8134 } 8135 ge('calDetSub').textContent = sub; 8136 8137 var body = ge('calDetBody'); 8138 body.innerHTML = ''; 8139 var actions = ge('calDetActions'); 8140 actions.innerHTML = ''; 8141 8142 if(ev.dataType === 'custom'){ 8143 var fields = [ 8144 {label:'Type', val:ev.data.type||'Event'}, 8145 {label:'Date', val:fd(ev.data.date)}, 8146 {label:'Time', val:ev.data.time ? fmtTime12(ev.data.time) : 'Not set'}, 8147 {label:'Notes', val:ev.data.notes || ''} 8148 ]; 8149 fields.forEach(function(f){ 8150 if(!f.val) return; 8151 var row = document.createElement('div'); 8152 row.style.cssText = 'display:flex;gap:10px;padding:6px 0;border-bottom:1px solid var(--border);font-size:15px;'; 8153 row.innerHTML = '<span style="color:var(--text3);min-width:60px;">' + f.label + ':</span><span style="color:var(--text);">' + f.val + '</span>'; 8154 body.appendChild(row); 8155 }); 8156 8157 // Edit button for custom events 8158 var editBtn = document.createElement('button'); 8159 editBtn.className = 'btn btn-g'; 8160 editBtn.textContent = 'Edit'; 8161 (function(evData){ editBtn.addEventListener('click', function(){ 8162 openEditCalEvent(evData); 8163 ge('calDetOv').classList.remove('open'); 8164 }); })(ev.data); 8165 actions.appendChild(editBtn); 8166 8167 // Delete button 8168 var delBtn = document.createElement('button'); 8169 delBtn.style.cssText = 'background:rgba(201,76,76,0.15);color:var(--danger);border:1px solid rgba(201,76,76,0.3);border-radius:7px;padding:8px 14px;cursor:pointer;font-family:DM Sans,sans-serif;font-size:14px;'; 8170 delBtn.textContent = 'Delete'; 8171 (function(evId){ delBtn.addEventListener('click', function(){ 8172 if(!confirm('Delete this event? If it was added to Google Calendar, it will be removed there too.')) return; 8173 var gmap = _gcalMap[String(evId)]; 8174 CAL.events = CAL.events.filter(function(e){ return e.id !== evId; }); 8175 saveCalEvents();
8176 ge('calDetOv').classList.remove('open'); 8177 renderCalendar(); 8178 // Remove the matching Google Calendar event, if we created one. 8179 if(gmap && gmap.gcalId){ 8180 postApi('/api/gcal', { action:'delete', memberId: gmap.mid, eventId: gmap.gcalId }).catch(function(){}); 8181 delete _gcalMap[String(evId)]; saveGcalMap(); 8182 } 8183 }); })(ev.data.id); 8184 actions.appendChild(delBtn); 8185 8186 } else if(ev.dataType === 'deadline'){ 8187 var c2 = gc(ev.data.contactId); 8188 body.innerHTML = '<div style="font-size:15px;color:var(--text2);">' 8189 + '<div style="margin-bottom:8px;"><b style="color:var(--text);">Type:</b> ' + ev.data.type + '</div>' 8190 + '<div style="margin-bottom:8px;"><b style="color:var(--text);">Date:</b> ' + fd(ev.data.date) + '</div>' 8191 + (c2 ? '<div><b style="color:var(--text);">Client:</b> ' + fn(c2) + '</div>' : '') 8192 + '</div>'; 8193 var goBtn = document.createElement('button'); 8194 goBtn.className = 'btn btn-p'; 8195 goBtn.textContent = 'View Deadlines'; 8196 goBtn.addEventListener('click', function(){ sp('deadlines'); ge('calDetOv').classList.remove('open'); }); 8197 actions.appendChild(goBtn); 8198 8199 } else if(ev.dataType === 'followup'){ 8200 var c3 = gc(ev.data.contactId); 8201 body.innerHTML = '<div style="font-size:15px;color:var(--text2);">' 8202 + '<div style="margin-bottom:8px;"><b style="color:var(--text);">Task:</b> ' + ev.data.label + '</div>' 8203 + '<div style="margin-bottom:8px;"><b style="color:var(--text);">Priority:</b> ' + (ev.data.pri||'normal') + '</div>' 8204 + (c3 ? '<div><b style="color:var(--text);">Client:</b> ' + fn(c3) + '</div>' : '') 8205 + '</div>'; 8206 var goBtn2 = document.createElement('button'); 8207 goBtn2.className = 'btn btn-p'; 8208 goBtn2.textContent = 'View Follow-ups'; 8209 goBtn2.addEventListener('click', function(){ sp('followups'); ge('calDetOv').classList.remove('open'); }); 8210 actions.appendChild(goBtn2); 8211 8212 } else if(ev.dataType === 'gcal'){ 8213 var gcalEv = ev.data; 8214 var memberInfo = ''; 8215 if(ev.memberId){ 8216 var mGcal = TM.find(function(x){ return String(x.id)===String(ev.memberId); }); 8217 memberInfo = mGcal ? mGcal.first + ' ' + mGcal.last : ''; 8218 } 8219 body.innerHTML = '<div style="font-size:15px;color:var(--text2);">' 8220 + (gcalEv.time ? '<div style="margin-bottom:8px;"><b style="color:var(--text);">Time:</b> ' + gcalEv.time + (gcalEv.endTime ? ' - ' + gcalEv.endTime : '') + '</div>' : '') 8221 + (gcalEv.location ? '<div style="margin-bottom:8px;"><b style="color:var(--text);">Location:</b> ' + gcalEv.location + '</div>' : '') 8222 + (gcalEv.description ? '<div style="margin-bottom:8px;"><b style="color:var(--text);">Description:</b> ' + gcalEv.description.substring(0,200) + '</div>' : '') 8223 + (memberInfo ? '<div style="margin-bottom:8px;"><b style="color:var(--text);">Calendar:</b> ' + memberInfo + (gcalEv.gcalMemberEmail ? ' (' + gcalEv.gcalMemberEmail + ')' : '') + '</div>' : '') 8224 + '<div style="margin-top:8px;font-size:13px;color:var(--text3);">From Google Calendar - edit this on Google.</div>' 8225 + '</div>'; 8226 8227 } else if(ev.dataType === 'transaction' || ev.dataType === 'txdate'){ 8228 var c4 = gc(ev.data.contactId); 8229 body.innerHTML = '<div style="font-size:15px;color:var(--text2);">' 8230 + '<div style="margin-bottom:8px;"><b style="color:var(--text);">Property:</b> ' + (ev.data.address||'Unknown') + '</div>' 8231 + '<div style="margin-bottom:8px;"><b style="color:var(--text);">Price:</b> ' + (ev.data.price||'-') + '</div>' 8232 + (c4 ? '<div><b style="color:var(--text);">Client:</b> ' + fn(c4) + '</div>' : '') 8233 + '</div>'; 8234 var goBtn3 = document.createElement('button'); 8235 goBtn3.className = 'btn btn-p'; 8236 goBtn3.textContent = 'View Transaction'; 8237 (function(txId){ goBtn3.addEventListener('click', function(){ sp('tc'); openTCDetail(txId); ge('calDetOv').classList.remove('open'); }); })(ev.data.id); 8238 actions.appendChild(goBtn3); 8239 } 8240 8241 // "Add to Google Cal" only for CRM items that aren't already synced: deadlines, follow-ups, and 8242 // transaction dates. Custom events sync automatically on save; Google events are already there. 8243 if(ev.dataType === 'deadline' || ev.dataType === 'followup' || ev.dataType === 'transaction' || ev.dataType === 'txdate'){ 8244 var gcalBtn = document.createElement('button'); 8245 gcalBtn.className = 'btn btn-g'; 8246 gcalBtn.style.cssText += 'display:flex;align-items:center;gap:6px;'; 8247 gcalBtn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> Add to Google Cal'; 8248 (function(e){ gcalBtn.addEventListener('click', function(){ syncEventToGCal(e); }); })(ev); 8249 actions.appendChild(gcalBtn); 8250 } 8251 8252 var closeBtn = document.createElement('button'); 8253 closeBtn.className = 'btn btn-g'; 8254 closeBtn.textContent = 'Close'; 8255 closeBtn.addEventListener('click', function(){ ge('calDetOv').
8255classList.remove('open'); }); 8256 actions.appendChild(closeBtn); 8257 8258 detOv.classList.add('open'); 8259} 8260 8261var calContactPicker = null; 8262function populateCalModal(){ 8263 var calCP = ge('calEvContactPick'); calCP.innerHTML = ''; 8264 calContactPicker = buildContactPicker(calCP, 'calEvContact', 'Search contact (optional)...'); 8265 populateAssignDropdowns(); 8266} 8267 8268function openAddCalEvent(){ 8269 ge('calEventModalTitle').textContent = 'Add Event'; 8270 ge('btnSaveCalEvent').setAttribute('data-edit-id',''); 8271 ge('btnDeleteCalEvent').style.display = 'none'; 8272 ge('calEvTitle').value = ''; 8273 ge('calEvDate').value = CAL.selectedDate || tod(); 8274 ge('calEvTime').value = ''; 8275 ge('calEvEndTime').value = ''; 8276 ge('calEvType').value = 'custom'; 8277 ge('calEvNotes').value = ''; 8278 var _sg = ge('calEvSyncGCal'); if(_sg){ _sg.checked = true; _sg.parentElement.style.display = ''; } 8279 populateCalModal(); 8280 om('calEventModal'); 8281} 8282 8283function openEditCalEvent(ev){ 8284 ge('calEventModalTitle').textContent = 'Edit Event'; 8285 // Checkbox reflects whether this event is currently on Google Calendar. Ticking/unticking on 8286 // save will create/update/remove it accordingly. 8287 var _sg2 = ge('calEvSyncGCal'); if(_sg2){ if(_sg2.parentElement) _sg2.parentElement.style.display = ''; _sg2.checked = !!_gcalMap[String(ev.id)]; } 8288 ge('btnSaveCalEvent').setAttribute('data-edit-id', ev.id); 8289 ge('btnDeleteCalEvent').style.display = 'inline-flex'; 8290 ge('calEvTitle').value = ev.title||''; 8291 ge('calEvDate').value = ev.date||''; 8292 ge('calEvTime').value = ev.time||''; 8293 ge('calEvEndTime').value = ev.endTime||''; 8294 ge('calEvType').value = ev.type||'custom'; 8295 ge('calEvNotes').value = ev.notes||''; 8296 populateCalModal(); 8297 ge('calEvMember').value = ev.memberId||''; 8298 if(calContactPicker && ev.contactId){ var calC = gc(ev.contactId); if(calC) calContactPicker.setContact(calC); } 8299 om('calEventModal'); 8300} 8301 8302function saveCalEvent(){ 8303 var title = ge('calEvTitle').value.trim(); 8304 if(!title){ alert('Please enter a title.'); return; } 8305 var editId = ge('btnSaveCalEvent').getAttribute('data-edit-id'); 8306 if(editId){ 8307 var ev = CAL.events.find(function(e){ return String(e.id)===String(editId); }); 8308 if(ev){ 8309 ev.title=title; ev.date=ge('calEvDate').value; ev.time=ge('calEvTime').value; 8310 ev.endTime=ge('calEvEndTime').value; ev.type=ge('calEvType').value; 8311 ev.memberId=parseInt(ge('calEvMember').value)||null; 8312 ev.contactId=parseInt(ge('calEvContact').value)||null; 8313 ev.notes=ge('calEvNotes').value; 8314 8315 // ---- Keep Google Calendar in step with the edit ---- 8316 var wantSyncE = ge('calEvSyncGCal') ? ge('calEvSyncGCal').checked : false; 8317 var gmapE = _gcalMap[String(editId)]; 8318 var _cE = ev.contactId ? gc(ev.contactId) : null; 8319 var evDataE = { title:ev.title, date:ev.date, time:ev.time, endTime:ev.endTime, type:ev.type, clientName:_cE?fn(_cE):'', notes:ev.notes||'' }; 8320 if(wantSyncE && gmapE && gmapE.gcalId){ 8321 // Update the existing Google event. 8322 postApi('/api/gcal', { action:'update', memberId:gmapE.mid, eventId:gmapE.gcalId, event:evDataE }).catch(function(){}); 8323 } else if(wantSyncE){ 8324 // Wasn't on Google yet - add it now. 8325 syncToGCal(evDataE, ev.memberId).then(function(res){ if(res && res.gcalId){ _gcalMap[String(editId)] = { gcalId:res.gcalId, mid:res.mid }; saveGcalMap(); } }); 8326 } else if(!wantSyncE && gmapE && gmapE.gcalId){ 8327 // Unticked - remove it from Google. 8328 postApi('/api/gcal', { action:'delete', memberId:gmapE.mid, eventId:gmapE.gcalId }).catch(function(){}); 8329 delete _gcalMap[String(editId)]; saveGcalMap(); 8330 } 8331 } 8332 } else { 8333 var newEv = { 8334 id: Date.now(), 8335 title: title, 8336 date: ge('calEvDate').value, 8337 time: ge('calEvTime').value, 8338 endTime: ge('calEvEndTime').value, 8339 type: ge('calEvType').value, 8340 memberId: parseInt(ge('calEvMember').value)||null, 8341 contactId: parseInt(ge('calEvContact').value)||null, 8342 notes: ge('calEvNotes').value 8343 }; 8344 CAL.events.push(newEv); 8345 saveCalEvents(); 8346 cm('calEventModal'); 8347 renderCalendar(); 8348 // Sync the new event to Google Calendar (only on create - editing would create a duplicate). 8349 if(ge('calEvSyncGCal') ? ge('calEvSyncGCal').checked : true){ 8350 var _c = newEv.contactId ? gc(newEv.contactId) : null; 8351 syncToGCal({ 8352 title: newEv.title, date: newEv.date, time: newEv.time, endTime: newEv.endTime, 8353 type: newEv.type, clientName: _c ? fn(_c) : '', notes: newEv.notes || '' 8354 }, newEv.memberId).then(function(res){ 8355 if(res && res.gcalId){ _gcalMap[String(newEv.id)] = { gcalId: res.gcalId, mid: res.mid }; saveGcalMap(); } 8356 }); 8357 } 8358 return; 8359 } 8360 saveCalEvents(); 8361 cm('calEventModal');
8362 renderCalendar(); 8363} 8364 8365function deleteCalEvent(){ 8366 var editId = ge('btnSaveCalEvent').getAttribute('data-edit-id'); 8367 if(!editId || !confirm('Delete this event? If it was added to Google Calendar, it will be removed there too.')) return; 8368 var gmap = _gcalMap[String(editId)]; 8369 CAL.events = CAL.events.filter(function(e){ return String(e.id)!==String(editId); }); 8370 saveCalEvents(); 8371 cm('calEventModal'); 8372 renderCalendar(); 8373 // Remove the matching Google Calendar event, if we created one. 8374 if(gmap && gmap.gcalId){ 8375 postApi('/api/gcal', { action:'delete', memberId: gmap.mid, eventId: gmap.gcalId }).catch(function(){}); 8376 delete _gcalMap[String(editId)]; saveGcalMap(); 8377 } 8378} 8379 8380// -- TEAM MANAGEMENT --- 8381var selectedMemberColor = '#C9A84C'; 8382 8383function initTeam(){ 8384 // Load TM from localStorage if not loaded 8385 try{ 8386 if(!TM.length){ 8387 var saved = localStorage.getItem('crm_tm'); 8388 if(saved) TM = JSON.parse(saved); 8389 } 8390 }catch(e){} 8391 8392 // NO local seeding. Team members live in Supabase so ids are shared across machines. 8393 // Inventing {id:1, id:2} here is what made Elda's browser see a team that matched no 8394 // gmail_token and no assignedTo value - failing silently instead of saying "not set up". 8395 renderTeamCards(); 8396 renderTeamAssignments(); 8397 populateTeamFilters(); 8398} 8399 8400function tmIni(m){ 8401 return (m.first||'?').charAt(0).toUpperCase() + (m.last||'').charAt(0).toUpperCase(); 8402} 8403 8404function renderTeamCards(){ 8405 var grid = ge('teamGrid'); 8406 if(!grid) return; 8407 grid.innerHTML = ''; 8408 8409 TM.forEach(function(m){ 8410 // Count assigned items 8411 // Closed deals aren't live work - keep them out of the workload counts. 8412 var contacts = C.filter(function(c){ return c.assignedTo === m.id; }).length; 8413 var transactions = TX.filter(function(t){ return t.assignedTo === m.id && t.status !== 'closed'; }).length; 8414 var deadlines = D.filter(function(d){ return d.assignedTo === m.id && !dlIsClosed(d); }).length; 8415 var followups = F.filter(function(f){ return f.assignedTo === m.id && !f.done && !dlIsClosed(f); }).length; 8416 8417 var card = document.createElement('div'); 8418 card.className = 'team-card'; 8419 8420 var av = document.createElement('div'); 8421 av.className = 'team-avatar'; 8422 av.style.background = (m.color||'#C9A84C') + '22'; 8423 av.style.color = m.color||'#C9A84C'; 8424 av.style.border = '2px solid ' + (m.color||'#C9A84C') + '44'; 8425 av.textContent = tmIni(m); 8426 8427 var name = document.createElement('div'); 8428 name.className = 'team-name'; 8429 name.textContent = m.first + ' ' + m.last; 8430 8431 var role = document.createElement('div'); 8432 role.className = 'team-role'; 8433 role.textContent = m.role || 'Agent'; 8434 8435 var stats = document.createElement('div'); 8436 stats.className = 'team-stats'; 8437 [{val:contacts,lbl:'Clients'},{val:transactions,lbl:'Deals'},{val:followups,lbl:'Follow-ups'}].forEach(function(s){ 8438 var st = document.createElement('div'); 8439 st.className = 'team-stat'; 8440 var v = document.createElement('div'); v.className='team-stat-val'; v.textContent=s.val; 8441 var l = document.createElement('div'); l.className='team-stat-lbl'; l.textContent=s.lbl; 8442 st.appendChild(v); st.appendChild(l); stats.appendChild(st); 8443 }); 8444 8445 var actions = document.createElement('div'); 8446 actions.style.cssText = 'display:flex;flex-direction:column;gap:7px;'; 8447 8448 // Calendar link button 8449 if(m.calLink){ 8450 var calBtn = document.createElement('a'); 8451 calBtn.href = m.calLink; 8452 calBtn.target = '_blank'; 8453 calBtn.className = 'team-cal-btn'; 8454 calBtn.innerHTML = '<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> Open Calendar'; 8455 actions.appendChild(calBtn); 8456 } else { 8457 var calSetup = document.createElement('button'); 8458 calSetup.className = 'team-cal-btn'; 8459 calSetup.style.cssText += 'opacity:0.6;'; 8460 calSetup.innerHTML = '<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> Add Calendar Link'; 8461 (function(mid){ calSetup.addEventListener('click', function(){ openEditMember(mid); }); })(m.id); 8462 actions.appendChild(calSetup); 8463 } 8464 8465 // Edit/Delete buttons 8466 var btnRow = document.createElement('div'); 8467 btnRow.style.cssText = 'display:flex;gap:7px;'; 8468 var editBtn = document.createElement('button'); 8469 editBtn.className = 'btn btn-g'; 8470 editBtn.style.cssText += 'flex:1;font-size:18px;padding:5px;'; 8471 editBtn.textContent = 'Edit'; 8472 (function(mid){ editBtn.addEventListener('click', function(){ openEditMember(mid); }); })(m.id); 8473 var delBtn = document.createElement('button'); 8474 delBtn.style.cssText = 'background:rgba(201,76,76,0.1);border:1px solid rgba(201,76,76,0.2);border-radius:7px;padding:5px 10px;cursor:pointer;font-size:18px;color:var(--danger);font-family:DM Sans,sans-serif;'; 8475 delBtn.textContent = 'Remove'; 8476 (function(mid){ delBtn.addEventListener('click', function(){ 8477 if(!confirm('Remove this team member?')) return; 8478 TM = TM.filter(function(x){ return x.id !== mid; }); 8479 if(typeof gmailState!=='undefined' && gmailState.connectedAccounts) delete gmailState.connectedAccounts[String(mid)]; 8480 sv(); 8481 if(supaReady){ dbDeleteBy('team','id',mid); dbDeleteBy('gmail_tokens','member_id',mid); } // persist the removal + drop their mailbox 8482 renderTeamCards(); renderTeamAssignments(); 8483 }); })(m.id); 8484 btnRow.appendChild(editBtn); btnRow.appendChild(delBtn); 8485 actions.appendChild(btnRow); 8486 8487 card.appendChild(av); card.appendChild(name); card.appendChild(role); 8488 card.appendChild(stats); card.appendChild(actions); 8489 grid.appendChild(card); 8490 }); 8491} 8492 8493function populateTeamFilters(){ 8494 var sel = ge('teamFilterMember'); 8495 if(!sel) return; 8496 sel.innerHTML = '<option value="">All Members</option>'; 8497 TM.forEach(function(m){ 8498 var o = document.createElement('option'); 8499 o.value = m.id; 8500 o.textContent = m.first + ' ' + m.last; 8501 sel.appendChild(o); 8502 }); 8503} 8504 8505function renderTeamAssignments(){ 8506 var el = ge('teamAssignmentsList'); 8507 if(!el) return; 8508 el.innerHTML = ''; 8509 var filterMember = ge('teamFilterMember') ? parseInt(ge('teamFilterMember').value)||'' : ''; 8510 var filterType = ge('teamFilterType') ? ge('teamFilterType').value : ''; 8511 8512 var items = []; 8513 8514 // Contacts 8515 if(!filterType || filterType==='contact'){ 8516 C.filter(function(c){ return c.assignedTo; }
8516).forEach(function(c){ 8517 if(filterMember && c.assignedTo !== filterMember) return; 8518 items.push({type:'contact', icon:'C', label:fn(c), sub:c.type+' - '+c.stage, assignedTo:c.assignedTo, id:c.id, obj:c}); 8519 }); 8520 } 8521 // Transactions 8522 if(!filterType || filterType==='transaction'){ 8523 TX.filter(function(t){ return t.assignedTo && t.status !== 'closed'; }).forEach(function(t){ 8524 if(filterMember && t.assignedTo !== filterMember) return; 8525 var c2=gc(t.contactId); 8526 items.push({type:'transaction', icon:'T', label:t.address||'Unknown', sub:(c2?fn(c2):'')+(t.closingDate?' - Close: '+fd(t.closingDate):''), assignedTo:t.assignedTo, id:t.id, obj:t}); 8527 }); 8528 } 8529 // Deadlines 8530 if(!filterType || filterType==='deadline'){ 8531 D.filter(function(d){ return d.assignedTo && !dlIsClosed(d); }).forEach(function(d){ 8532 if(filterMember && d.assignedTo !== filterMember) return; 8533 var c3=gc(d.contactId); 8534 items.push({type:'deadline', icon:'D', label:d.type, sub:(c3?fn(c3):'Unknown')+' - '+fd(d.date), assignedTo:d.assignedTo, id:d.id, obj:d}); 8535 }); 8536 } 8537 // Follow-ups 8538 if(!filterType || filterType==='followup'){ 8539 F.filter(function(f){ return f.assignedTo && !f.done && !dlIsClosed(f); }).forEach(function(f){ 8540 if(filterMember && f.assignedTo !== filterMember) return; 8541 var c4=gc(f.contactId); 8542 items.push({type:'followup', icon:'F', label:f.label, sub:(c4?fn(c4):'Unknown')+' - Due: '+fd(f.date), assignedTo:f.assignedTo, id:f.id, obj:f}); 8543 }); 8544 } 8545 8546 if(!items.length){ 8547 el.innerHTML = '<div class="empty" style="padding:20px;">No assignments yet. Assign contacts, transactions, deadlines, and follow-ups to team members from their respective pages.</div>'; 8548 return; 8549 } 8550 8551 // Group by member 8552 var byMember = {}; 8553 items.forEach(function(item){ 8554 var key = item.assignedTo; 8555 if(!byMember[key]) byMember[key] = []; 8556 byMember[key].push(item); 8557 }); 8558 8559 Object.keys(byMember).forEach(function(mid){ 8560 var m = TM.find(function(x){ return x.id == mid; }); 8561 if(!m) return; 8562 8563 var secHdr = document.createElement('div'); 8564 secHdr.style.cssText = 'padding:10px 16px;background:var(--surface2);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;'; 8565 var av2 = document.createElement('div'); 8566 av2.style.cssText = 'width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;flex-shrink:0;background:'+(m.color||'#C9A84C')+'22;color:'+(m.color||'#C9A84C')+';'; 8567 av2.textContent = tmIni(m); 8568 var mName = document.createElement('div'); 8569 mName.style.cssText = 'font-size:18px;font-weight:600;color:var(--text);'; 8570 mName.textContent = m.first + ' ' + m.last + ' - ' + byMember[mid].length + ' assignment' + (byMember[mid].length!==1?'s':''); 8571 secHdr.appendChild(av2); secHdr.appendChild(mName); 8572 8573 // Calendar button in section header 8574 if(m.calLink){ 8575 var calLink2 = document.createElement('a'); 8576 calLink2.href = m.calLink; 8577 calLink2.target = '_blank'; 8578 calLink2.style.cssText = 'margin-left:auto;font-size:18px;color:var(--buyer);text-decoration:none;display:flex;align-items:center;gap:4px;'; 8579 calLink2.innerHTML = '<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> Calendar'; 8580 secHdr.appendChild(calLink2); 8581 } 8582 8583 el.appendChild(secHdr); 8584 8585 byMember[mid].forEach(function(item){ 8586 var row = document.createElement('div'); 8587 row.style.cssText = 'padding:10px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;'; 8588 8589 var typeBadge = document.createElement('span'); 8590 var typeColors = {contact:'var(--buyer)',transaction:'var(--accent)',deadline:'var(--danger)',followup:'var(--lead)'}; 8591 typeBadge.style.cssText = 'font-size:9px;font-weight:700;padding:2px 6px;border-radius:3px;text-transform:uppercase;background:'+typeColors[item.type]+'22;color:'+typeColors[item.type]+';flex-shrink:0;'; 8592 typeBadge.textContent = item.type; 8593 8594 var info = document.createElement('div'); 8595 info.style.flex = '1'; 8596 var lbl = document.createElement('div'); 8597 lbl.style.cssText = 'font-size:18px;color:var(--text);'; 8598 lbl.textContent = item.label; 8599 var sub = document.createElement('div'); 8600 sub.style.cssText = 'font-size:18px;color:var(--text3);margin-top:1px;'; 8601 sub.textContent = item.sub; 8602 info.appendChild(lbl); info.appendChild(sub); 8603 8604 // Unassign button 8605 var unBtn = document.createElement('button'); 8606 unBtn.style.cssText = 'background:none;border:none;color:var(--text3);cursor:pointer;font-size:18px;'; 8607 unBtn.textContent = 'Unassign'; 8608 (function(it){ unBtn.addEventListener('click', function(){ 8609 it.obj.assignedTo = null; 8610 sv(); 8611 if(supaReady){ 8612 var tbl = it.type==='contact'?'contacts':it.type==='transaction'?'transactions':it.type==='deadline'?'deadlines':'followups'; 8613 dbSave(tbl, [it.obj]); 8614 } 8615 renderTeamAssignments(); renderTeamCards(); 8616 }); })(item); 8617 8618 row.appendChild(typeBadge); row.appendChild(info);
8618 row.appendChild(unBtn); 8619 el.appendChild(row); 8620 }); 8621 }); 8622} 8623 8624function openAddMember(){ 8625 ge('memberModalTitle').textContent = 'Add Team Member'; 8626 ge('btnSaveMember').setAttribute('data-edit-id',''); 8627 ge('mFirst').value=''; ge('mLast').value=''; ge('mEmail').value=''; ge('mPhone').value=''; ge('mCalLink').value=''; 8628 ge('mRole').value='Agent'; 8629 selectedMemberColor='#C9A84C'; 8630 document.querySelectorAll('.color-swatch').forEach(function(s){ 8631 s.style.border = s.getAttribute('data-color')==='#C9A84C' ? '2px solid white' : '2px solid transparent'; 8632 }); 8633 om('memberModal'); 8634} 8635 8636function openEditMember(id){ 8637 var m = TM.find(function(x){ return x.id===id; }); 8638 if(!m) return; 8639 ge('memberModalTitle').textContent = 'Edit Team Member'; 8640 ge('btnSaveMember').setAttribute('data-edit-id', id); 8641 ge('mFirst').value = m.first||''; 8642 ge('mLast').value = m.last||''; 8643 ge('mEmail').value = m.email||''; 8644 ge('mPhone').value = m.phone||''; 8645 ge('mCalLink').value = m.calLink||''; 8646 ge('mRole').value = m.role||'Agent'; 8647 selectedMemberColor = m.color||'#C9A84C'; 8648 document.querySelectorAll('.color-swatch').forEach(function(s){ 8649 s.style.border = s.getAttribute('data-color')===selectedMemberColor ? '2px solid white' : '2px solid transparent'; 8650 }); 8651 om('memberModal'); 8652} 8653 8654function saveMember(){ 8655 var editId = parseInt(ge('btnSaveMember').getAttribute('data-edit-id'))||''; 8656 if(editId){ 8657 var m=TM.find(function(x){return x.id===editId;}); 8658 if(m){ 8659 m.first=ge('mFirst').value.trim(); m.last=ge('mLast').value.trim(); 8660 m.role=ge('mRole').value; m.email=ge('mEmail').value.trim(); 8661 m.phone=ge('mPhone').value.trim(); m.calLink=ge('mCalLink').value.trim(); 8662 m.color=selectedMemberColor; 8663 } 8664 } else { 8665 TM.push({id:Date.now(), first:ge('mFirst').value.trim(), last:ge('mLast').value.trim(), 8666 role:ge('mRole').value, email:ge('mEmail').value.trim(), phone:ge('mPhone').value.trim(), 8667 calLink:ge('mCalLink').value.trim(), color:selectedMemberColor}); 8668 } 8669 // Persist the whole team to Supabase so every machine sees the same ids. 8670 TM.forEach(function(m){ saveTM(m); }); 8671 sv(); cm('memberModal'); 8672 renderTeamCards(); renderTeamAssignments(); populateTeamFilters(); 8673 // Also update assignment dropdowns everywhere 8674 populateAssignDropdowns(); 8675} 8676 8677function populateAssignDropdowns(){ 8678 // Update all assign-to selects across the app 8679 document.querySelectorAll('.assign-to-select').forEach(function(sel){ 8680 var cur = sel.value; 8681 sel.innerHTML = '<option value="">Unassigned</option>'; 8682 TM.forEach(function(m){ 8683 var o=document.createElement('option'); 8684 o.value=m.id; o.textContent=m.first+' '+m.last; 8685 sel.appendChild(o); 8686 }); 8687 if(cur) sel.value=cur; 8688 }); 8689} 8690 8691// -- GMAIL --- 8692var gmailState = { 8693 activeMemberId: null, 8694 activeThreadId: null, 8695 messages: [], 8696 connectedAccounts: {}, // memberId -> {email, name} 8697 composing: false, 8698 replyToMessageId: null 8699}; 8700 8701// Email templates 8702var EMAIL_TEMPLATES = { 8703 new_buyer: { 8704 subject: 'Welcome - Your Home Search Plan', 8705 body: ['Hi {{firstName}},', '', 'Thank you for reaching out! I am excited to help you find your perfect home.', '', 'I would love to schedule a quick 15-minute call to learn more about what you are looking for. We can cover:', '- Your target neighborhoods and must-haves', '- Your timeline and budget range', '- The current market and what to expect', '', 'When would be a good time to connect?', '', 'Best regards,', '{{agentName}}', 'Palacios Baker Real Estate', '(801) 910-2296 | palaciosbaker.com'].join('\n') 8706 }, 8707 new_seller: { 8708 subject: 'Your Home Value - Next Steps', 8709 body: ['Hi {{firstName}},', '', 'Thank you for reaching out about selling your home at {{propertyAddress}}!', '', 'I have prepared a market analysis for your area and would love to walk you through it. I will show you:', '- What comparable homes are selling for right now', '- How to position your home for maximum value', '- Our proven marketing strategy', '', 'Would you be available for a 20-minute call this week?', '', 'Best regards,', '{{agentName}}', 'Palacios Baker Real Estate'].join('\n') 8710 }, 8711 showing_confirm: { 8712 subject: 'Showing Confirmation - {{propertyAddress}}', 8713 body: ['Hi {{firstName}},', '', 'Just confirming your showing at {{propertyAddress}}.', '', 'Please let me know if you have any questions before we meet. I look forward to seeing you there!', '', 'Be
8713st regards,', '{{agentName}}', 'Palacios Baker Real Estate'].join('\n') 8714 }, 8715 under_contract: { 8716 subject: 'We Are Under Contract! - Next Steps', 8717 body: ['Hi {{firstName}},', '', 'Congratulations! We are officially under contract on {{propertyAddress}}.', '', 'Here are your key upcoming deadlines:', '- Earnest Money Due: Please confirm with your lender', '- Inspection Period: We will schedule this shortly', '- Financing Deadline: Work closely with your lender', '- Closing Date: {{closingDate}}', '', 'I will be in touch with updates every step of the way. Do not hesitate to reach out with any questions!', '', 'Best regards,', '{{agentName}}', 'Palacios Baker Real Estate'].join('\n') 8718 }, 8719 closing_reminder: { 8720 subject: 'Closing Reminder - {{propertyAddress}}', 8721 body: ['Hi {{firstName}},', '', 'Just a friendly reminder that we are closing on {{propertyAddress}} on {{closingDate}}.', '', 'Please remember to:', '- Bring a valid government-issued ID', '- Confirm wire transfer details DIRECTLY with the title company by phone (never by email)', '- Do a final walkthrough the day before if possible', '', 'So excited for you! See you at closing.', '', 'Best regards,', '{{agentName}}', 'Palacios Baker Real Estate'].join('\n') 8722 }, 8723 post_close: { 8724 subject: 'Congratulations on Your New Home!', 8725 body: ['Hi {{firstName}},', '', 'Congratulations on the closing of {{propertyAddress}}! It was such a pleasure working with you.', '', 'A few reminders as you settle in:', '- Change the locks on your new home', '- Update your address with USPS, your bank, and DMV', '- Keep your closing documents in a safe place', '', 'If you know anyone who is thinking about buying or selling, I would be honored by the referral.', '', 'Wishing you many happy years in your new home.', '', 'Warmly,', '{{agentName}}', 'Palacios Baker Real Estate'].join('\n') 8726 }, 8727 market_update: { 8728 subject: 'Utah County Market Update', 8729 body: ['Hi {{firstName}},', '', 'I wanted to share a quick update on the Utah County real estate market.', '', 'The market continues to be active with strong buyer demand. If you are thinking about buying or selling, now is a great time to have a conversation.', '', 'I am always available for a no-pressure market consultation. Would you like to connect soon?', '', 'Best regards,', '{{agentName}}', 'Palacios Baker Real Estate', '(801) 910-2296'].join('\n') 8730 } 8731}; 8732 8733function initGmail(){ 8734 // TM comes from Supabase via loadFromDB; localStorage is only a cache for offline reloads. 8735 if(!TM.length){ 8736 try{ var s=localStorage.getItem('crm_tm'); if(s) TM=JSON.parse(s); }catch(e){} 8737 } 8738 renderGmailAccountBar(); 8739 checkConnectedAccounts(); 8740} 8741 8742async function checkConnectedAccounts(){ 8743 // Check Supabase for stored tokens - use hardcoded values as fallback 8744 var supaU = SUPA_URL || 'https://fgkilooomlozhwfnvjze.supabase.co'; 8745 var supaK = SUPA_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZna2lsb29vbWxvemh3Zm52anplIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODA3NTc0NTIsImV4cCI6MjA5NjMzMzQ1Mn0.owQk8Vy3Vcs8n8c0sI0fXQYmjpAy14hev8lDt4g5iZE'; 8746 if(!supaU) return; 8747 try{ 8748 var resp = await fetch(supaU + '/rest/v1/gmail_tokens?select=member_id,email,name', { 8749 headers: await getAuthHeaders() 8750 }); 8751 var tokens = await resp.json(); 8752 if(Array.isArray(tokens)){ 8753 tokens.forEach(function(t){ 8754 gmailState.connectedAccounts[String(t.member_id)] = {email:t.email, name:t.name}; 8755 }); 8756 } 8757 renderGmailAccountBar(); 8758 // Show the inbox belonging to whoever is signed in - NOT just the lowest member id, 8759 // which always resolved to member 1 and showed Randy's mail to everyone. 8760 // Match on the connected Gmail address first: it comes straight from gmail_tokens and 8761 // needs no TM data (TM is localStorage-only and may not be loaded yet at this point). 8762 var preferred = null; 8763 var myEmail = (currentUser && currentUser.email) ? currentUser.email.toLowerCase() : ''; 8764 // 1) This user's remembered choice from last time (per-login), if still connected. 8765 try{ var pk=_gmailPrefKey(); var saved=pk?localStorage.getItem(pk):''; if(saved && gmailState.connectedAccounts[String(saved)]) preferred=String(saved); }catch(e){} 8766 // 2) A connected account whose Gmail address matches the signed-in user. 8767 if(!preferred && myEmail){
8768 Object.keys(gmailState.connectedAccounts).forEach(function(k){ 8769 var a = gmailState.connectedAccounts[k]; 8770 if(!preferred && a && a.email && String(a.email).toLowerCase() === myEmail) preferred = k; 8771 }); 8772 } 8773 // 3) Match via the team-member record (covers a CRM login that differs from the Gmail address). 8774 if(!preferred){ 8775 var mid = resolveMyMember(); 8776 if(mid != null && gmailState.connectedAccounts[String(mid)]) preferred = String(mid); 8777 } 8778 // 4) Last resort: the first connected account that ISN'T the rental scan-only mailbox, so nobody 8779 // lands on banff1997 by default; only fall back to it if it's the only thing connected. 8780 if(!preferred){ 8781 var keys=Object.keys(gmailState.connectedAccounts); 8782 preferred = keys.find(function(k){ var a=gmailState.connectedAccounts[k]; return !a || String(a.email||'').toLowerCase()!==GMAIL_SCAN_ONLY; }) || keys[0]; 8783 } 8784 if(preferred){ 8785 gmailState.activeMemberId = preferred; 8786 ge('gmailNotConnected').style.display = 'none'; 8787 ge('gmailConnected').style.display = 'block'; 8788 ge('btnGmailCompose').style.display = 'inline-flex'; 8789 ge('btnGmailRefresh').style.display = 'inline-flex'; 8790 loadGmailInbox(); 8791 } 8792 }catch(e){ console.log('checkConnectedAccounts error:', e); } 8793} 8794 8795function renderGmailAccountBar(){ 8796 var bar = ge('gmailAccountBar'); 8797 var connectBtns = ge('gmailConnectButtons'); 8798 if(!bar) return; 8799 bar.innerHTML = ''; 8800 8801 // TM comes from Supabase (loadFromDB); localStorage is only an offline cache. No seeding: 8802 // invented ids match no gmail_token and no assignedTo, which is what broke Elda's view. 8803 if(!TM.length){ 8804 try{ 8805 var saved = localStorage.getItem('crm_tm'); 8806 if(saved) TM = JSON.parse(saved); 8807 }catch(e){} 8808 } 8809 8810 // Show connected accounts as tabs 8811 TM.forEach(function(m){ 8812 var acc = gmailState.connectedAccounts[String(m.id)]; 8813 if(acc){ 8814 var btn = document.createElement('button'); 8815 btn.style.cssText = 'display:flex;align-items:center;gap:7px;padding:6px 12px;border-radius:7px;border:1px solid '+(gmailState.activeMemberId==m.id?'var(--accent)':'var(--border)')+';background:'+(gmailState.activeMemberId==m.id?'rgba(201,168,76,0.1)':'var(--surface)')+';cursor:pointer;font-size:18px;font-family:DM Sans,sans-serif;color:var(--text);transition:all 0.15s;'; 8816 var av = document.createElement('div'); 8817 av.style.cssText = 'width:20px;height:20px;border-radius:50%;background:'+(m.color||'#C9A84C')+'33;color:'+(m.color||'#C9A84C')+';display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:700;flex-shrink:0;'; 8818 av.textContent = tmIni(m); 8819 btn.appendChild(av); 8820 var txt = document.createElement('div'); 8821 txt.innerHTML = '<div style="font-size:18px;font-weight:600;">' + m.first + '</div><div style="font-size:18px;color:var(--text3);">
8821' + acc.email + '</div>'; 8822 btn.appendChild(txt); 8823 var disBtn = document.createElement('button'); 8824 disBtn.style.cssText='background:rgba(201,76,76,0.1);border:1px solid rgba(201,76,76,0.2);border-radius:4px;color:var(--danger);cursor:pointer;font-size:18px;padding:2px 5px;margin-left:4px;font-family:DM Sans,sans-serif;'; 8825 disBtn.title='Disconnect Gmail'; 8826 disBtn.textContent='Disconnect'; 8827 (function(mid){ disBtn.addEventListener('click', function(e){ e.stopPropagation(); disconnectGmail(mid); }); })(m.id); 8828 btn.appendChild(disBtn); 8829 (function(mid){ btn.addEventListener('click', function(){ switchGmailAccount(mid); }); })(m.id); 8830 bar.appendChild(btn); 8831 } 8832 }); 8833 8834 // Connect buttons for unconnected members. 8835 // These also go in the always-visible account bar. They used to live ONLY inside the 8836 // #gmailNotConnected panel, which is hidden the moment any one account connects - so 8837 // once Randy connected, "Connect Elda's Gmail" became permanently unreachable. 8838 TM.forEach(function(m){ 8839 if(!gmailState.connectedAccounts[String(m.id)]){ 8840 var bbtn = document.createElement('button'); 8841 bbtn.className = 'btn btn-g'; 8842 bbtn.style.cssText += 'display:flex;align-items:center;gap:6px;font-size:15px;padding:5px 10px;'; 8843 bbtn.textContent = '+ Connect ' + (m.first || 'member') + "'s Gmail"; 8844 (function(mid){ bbtn.addEventListener('click', function(){ connectGmail(mid); }); })(m.id); 8845 bar.appendChild(bbtn); 8846 } 8847 }); 8848 if(!TM.length){ 8849 bar.appendChild(mkDiv('font-size:15px;color:var(--text3);', 8850 'No team members yet - add them on the Team tab, then connect each Gmail.')); 8851 } 8852 8853 if(connectBtns){ 8854 connectBtns.innerHTML = ''; 8855 TM.forEach(function(m){ 8856 if(!gmailState.connectedAccounts[String(m.id)]){ 8857 var btn = document.createElement('button'); 8858 btn.className = 'btn btn-g'; 8859 btn.style.cssText += 'display:flex;align-items:center;gap:7px;'; 8860 btn.innerHTML = '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg> Connect ' + m.first + "'s Gmail"; 8861 (function(mid){ btn.addEventListener('click', function(){ connectGmail(mid); }); })(m.id); 8862 connectBtns.appendChild(btn); 8863 } 8864 }); 8865 } 8866} 8867 8868async function connectGmail(memberId){ 8869 try{ 8870 // Show loading state 8871 var connectBtns = ge('gmailConnectButtons'); 8872 if(connectBtns) connectBtns.innerHTML = '<div style="color:var(--text3);font-size:18px;">Connecting...</div>'; 8873 8874 // Send the team member's own address as a hint so Google's account chooser 8875 // highlights the right one - you still have to pick it deliberately. 8876 var _hm = TM.find(function(m){ return String(m.id) === String(memberId); }); 8877 var _hint = (_hm && _hm.email) ? '&hint=' + encodeURIComponent(_hm.email) : ''; 8878 var resp = await fetch('/api/gmail-auth?action=url&memberId=' + memberId + _hint); 8879 if(!resp.ok){ 8880 var errText = await resp.text(); 8881 alert('Server error: ' + errText); 8882 renderGmailAccountBar(); 8883 return; 8884 } 8885 var data = await resp.json(); 8886 if(data.error){ 8887 alert('Error getting auth URL: ' + data.error); 8888 renderGmailAccountBar(); 8889 return; 8890 } 8891 8892 // Try popup first, fall back to redirect if blocked 8893 var popup = window.open(data.url, 'gmail_auth', 'width=550,height=650,scrollbars=yes,resizable=yes'); 8894 8895 if(!popup || popup.closed || typeof popup.closed === 'undefined'){ 8896 // Popup was blocked - redirect in same tab with state 8897 if(confirm('Popup was blocked. Click OK to open the Google sign-in page. After signing in you will be redirected back.')){ 8898 localStorage.setItem('gmail_connect_member', memberId); 8899 window.location.href = data.url; 8900 } else { 8901 renderGmailAccountBar(); 8902 } 8903 return; 8904 } 8905 8906 // Listen for callback message from popup 8907 var handler = function(event){ 8908 if(!event.data) return; 8909 if(event.data.type === 'gmail_connected'){ 8910 window.removeEventListener('message', handler); 8911 gmailState.connectedAccounts[String(event.data.memberId)] = {email:event.data.email, name:event.data.name}; 8912 renderGmailAccountBar(); 8913 switchGmailAccount(String(event.data.memberId)); 8914 ge('gmailNotConnected').style.display = 'none'; 8915 ge('gmailConnected').style.display = 'block'; 8916 ge('btnGmailCompose').style.display = 'inline-flex'; 8917 ge('btnGmailRefresh').style.display = 'inline-flex'; 8918 alert('Gmail connected as ' + event.data.email + '!'); 8919 } else if(event.data.type === 'gmail_error'){ 8920 window.removeEventListener('message', handler); 8921 alert('Gmail connection failed: ' + event.data.error); 8922 renderGmailAccountBar(); 8923 } 8924 }; 8925 window.addEventListener('message', handler); 8926 8927 // Check if popup gets closed without completing 8928 var checkClosed = setInterval(function(){ 8929 if(popup.closed){ 8930 clearInterval(checkClosed); 8931 window.removeEventListener('message', handler); 8932 // Re-render in case something changed 8933 checkConnectedAccounts(); 8934 } 8935 }, 1000); 8936 8937 }catch(e){
8938 alert('Connection error: ' + e.message); 8939 renderGmailAccountBar(); 8940 } 8941} 8942 8943async function disconnectGmail(memberId){ 8944 if(!confirm('Disconnect Gmail for this account?')) return; 8945 try{ 8946 await fetch('/api/gmail-auth', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'revoke',memberId:memberId})}); 8947 delete gmailState.connectedAccounts[String(memberId)]; 8948 renderGmailAccountBar(); 8949 if(gmailState.activeMemberId == memberId){ 8950 var nextAccount = Object.keys(gmailState.connectedAccounts)[0]; 8951 if(nextAccount){ switchGmailAccount(nextAccount); } 8952 else { ge('gmailNotConnected').style.display='flex'; ge('gmailConnected').style.display='none'; ge('btnGmailCompose').style.display='none'; ge('btnGmailRefresh').style.display='none'; } 8953 } 8954 }catch(e){ alert('Error disconnecting: '+e.message); } 8955} 8956 8957// The mailbox that only exists for the rental scan - never auto-default to it. 8958var GMAIL_SCAN_ONLY = '[email protected]'; 8959function _gmailPrefKey(){ var e=(currentUser&¤tUser.email)?currentUser.email.toLowerCase():''; return e?('gmailPref:'+e):''; } 8960function switchGmailAccount(memberId){ 8961 gmailState.activeMemberId = memberId; 8962 try{ var k=_gmailPrefKey(); if(k) localStorage.setItem(k, String(memberId)); }catch(e){} // remember this user's choice 8963 var acc = gmailState.connectedAccounts[String(memberId)]; 8964 if(acc) ge('gmailAccountEmail').textContent = acc.email; 8965 renderGmailAccountBar(); 8966 loadGmailInbox(); 8967} 8968 8969async function loadGmailInbox(searchTerm){ 8970 if(!gmailState.activeMemberId) return; 8971 var filter = ge('gmailInboxFilter') ? ge('gmailInboxFilter').value : 'in:inbox'; 8972 // Default to whatever is in the search box, so refreshes and folder changes keep the search. 8973 if(typeof searchTerm !== 'string' && ge('gmailSearch')) searchTerm = ge('gmailSearch').value; 8974 if(typeof searchTerm === 'string' && searchTerm.trim()){ filter = filter + ' ' + searchTerm.trim(); } 8975 if(!gmailState.silentRefresh) ge('gmailMsgList').innerHTML = '<div style="padding:20px;text-align:center;color:var(--text3);font-size:18px;">Loading...</div>'; 8976 try{ 8977 var resp = await fetch('/api/gmail-api', { 8978 method:'POST', headers: await apiHeaders(), 8979 body: JSON.stringify({action:'inbox', memberId:gmailState.activeMemberId, query:filter}) 8980 }); 8981 var respText = await resp.text(); 8982 var data; 8983 try{ data = JSON.parse(respText); } 8984 catch(e){ 8985 ge('gmailMsgList').innerHTML='<div style="padding:20px;color:var(--danger);font-size:18px;">API error ('+resp.status+'): '+respText.substring(0,200)+'</div>'; 8986 return; 8987 } 8988 if(data.error){ ge('gmailMsgList').innerHTML='<div style="padding:20px;color:var(--danger);font-size:18px;">'+data.error+'</div>'; return; } 8989 gmailState.messages = data.messages || []; 8990 ge('gmailMsgCount').textContent = gmailState.messages.length + ' messages'; 8991 var unread = gmailState.messages.filter(function(m){return m.unread;}).length; 8992 ge('nbGmail').textContent = unread || ''; 8993 renderGmailList(); 8994 }catch(e){ ge('gmailMsgList').innerHTML='<div style="padding:20px;color:var(--danger);font-size:18px;">Error loading emails: '+e.message+'</div>'; } 8995} 8996 8997function renderGmailList(){ 8998 var el = ge('gmailMsgList'); 8999 el.innerHTML = ''; 9000 if(!gmailState.messages.length){ el.innerHTML='<div style="padding:20px;text-align:center;color:var(--text3);">No messages found.</div>'; return; } 9001 gmailState.messages.forEach(function(msg){ 9002 var item = document.createElement('div'); 9003 item.className = 'gmail-msg-item' + (msg.unread?' unread':'') + (gmailState.activeMsgId===msg.id?' active':''); 9004 var top = document.createElement('div'); 9005 top.style.cssText = 'display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:2px;'; 9006 var from = document.createElement('div'); 9007 from.className = 'gmail-msg-from'; 9008 // Extract just name from "Name <email>" 9009 var fromMatch = msg.from.match(/^([^<]+)</); 9010 from.textContent = fromMatch ? fromMatch[1].trim() : msg.from; 9011 // Match to contact 9012 var emailMatch = msg.from.match(/<(.+)>/); 9013 var fromEmail = emailMatch ? emailMatch[1] : msg.from; 9014 var matchedContact = C.find(function(c){ return c.email && c.email.toLowerCase()===fromEmail.toLowerCase(); }); 9015 if(matchedContact){ 9016 var tag = document.createElement('span'); 9017 tag.className = 'tag tag-'+matchedContact.type; 9018 tag.style.fontSize='9px'; 9019 tag.textContent = matchedContact.first; 9020 from.appendChild(tag); 9021 } 9022 var date = document.createElement('div'); 9023 date.className = 'gmail-msg-date';
9024 date.textContent = formatGmailDate(msg.date); 9025 top.appendChild(from); top.appendChild(date); 9026 var subj = document.createElement('div'); 9027 subj.className = 'gmail-msg-subject'; 9028 subj.textContent = msg.subject; 9029 if(msg.msgCount && msg.msgCount > 1){ 9030 var cnt = document.createElement('span'); 9031 cnt.style.cssText = 'color:var(--accent);font-weight:600;margin-left:6px;'; 9032 cnt.textContent = '(' + msg.msgCount + ')'; 9033 subj.appendChild(cnt); 9034 } 9035 var snip = document.createElement('div'); 9036 snip.className = 'gmail-msg-snippet'; 9037 snip.textContent = msg.snippet; 9038 if(msg.hasAttachment){ 9039 var att = document.createElement('span'); 9040 att.style.cssText='color:var(--accent);font-size:18px;margin-left:4px;'; 9041 att.textContent='[attachment]'; 9042 subj.appendChild(att); 9043 } 9044 item.appendChild(top); item.appendChild(subj); item.appendChild(snip); 9045 (function(m){ item.addEventListener('click', function(){ loadThread(m.threadId, m.id); }); })(msg); 9046 el.appendChild(item); 9047 }); 9048} 9049 9050function formatGmailDate(dateStr){ 9051 if(!dateStr) return ''; 9052 try{ 9053 var d = new Date(dateStr); 9054 var now = new Date(); 9055 var isToday = d.toDateString()===now.toDateString(); 9056 if(isToday) return d.toLocaleTimeString('en-US',{hour:'numeric',minute:'2-digit'}); 9057 return d.toLocaleDateString('en-US',{month:'short',day:'numeric'}); 9058 }catch(e){ return dateStr; } 9059} 9060 9061async function loadThread(threadId, messageId){ 9062 gmailState.activeThreadId = threadId; 9063 gmailState.activeMsgId = messageId; 9064 renderGmailList(); // Update active state 9065 9066 var thread = ge('gmailThread'); 9067 thread.innerHTML = '<div class="gmail-empty"><div style="font-size:18px;color:var(--text3);">Loading thread...</div></div>'; 9068 9069 try{ 9070 var resp = await fetch('/api/gmail-api', { 9071 method:'POST', headers: await apiHeaders(), 9072 body: JSON.stringify({action:'thread', memberId:gmailState.activeMemberId, threadId:threadId}) 9073 }); 9074 var respText2 = await resp.text(); 9075 var data; 9076 try{ data = JSON.parse(respText2); } 9077 catch(e){ thread.innerHTML='<div class="gmail-empty" style="color:var(--danger);">API error ('+resp.status+'): '+respText2.substring(0,200)+'</div>'; return; } 9078 if(data.error){ thread.innerHTML='<div class="gmail-empty" style="color:var(--danger);">'+data.error+'</div>'; return; } 9079 renderThread(data.messages, threadId); 9080 }catch(e){ thread.innerHTML='<div class="gmail-empty" style="color:var(--danger);">Error: '+e.message+'</div>'; } 9081} 9082 9083async function gmailThreadAction(action, threadId){ 9084 if(!threadId || !gmailState.activeMemberId) return; 9085 var thread = ge('gmailThread'); 9086 try{ 9087 var resp = await fetch('/api/gmail-api', { 9088 method:'POST', headers: await apiHeaders(), 9089 body: JSON.stringify({action:action, memberId:gmailState.activeMemberId, threadId:threadId}) 9090 }); 9091 var data = await resp.json(); 9092 if(data && data.error){ alert(action + ' failed: ' + data.error); return; } 9093 // Optimistically remove from the list 9094 gmailState.messages = gmailState.messages.filter(function(m){ return m.threadId !== threadId; }); 9095 if(ge('gmailMsgCount')) ge('gmailMsgCount').textContent = gmailState.messages.length + ' messages'; 9096 gmailState.activeThreadId = null; 9097 renderGmailList(); 9098 // Close the reading pane (returns to the list on mobile) 9099 var layout = ge('gmailLayout'); 9100 if(layout) layout.classList.remove('thread-open'); 9101 thread.innerHTML = '<div class="gmail-empty" id="gmailEmptyState"><svg width="36" height="36" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg><div>Select an email to read</div></div>'; 9102 }catch(e){ alert('Error: ' + e.message); } 9103} 9104 9105async function gmailLoadLabels(){ 9106 if(gmailState.labelCache) return gmailState.labelCache; 9107 try{ 9108 var resp = await fetch('/api/gmail-api', { 9109 method:'POST', headers: await apiHeaders(), 9110 body: JSON.stringify({action:'labels', memberId:gmailState.activeMemberId}) 9111 }); 9112 var data = await resp.json(); 9113 gmailState.labelCache = (data && data.labels) ? data.labels : []; 9114 }catch(e){ gmailState.labelCache = []; } 9115 return gmailState.labelCache; 9116} 9117 9118async function gmailToggleLabel(threadId, labelId, add){ 9119 try{ 9120 var resp = await fetch('/api/gmail-api', { 9121 method:'POST', headers: await apiHeaders(), 9122 body: JSON.stringify({ 9123 action:'modifyLabels', memberId:gmailState.activeMemberId, threadId:threadI
9123d, 9124 addLabelIds: add ? [labelId] : [], 9125 removeLabelIds: add ? [] : [labelId] 9126 }) 9127 }); 9128 var data = await resp.json(); 9129 if(data && data.error){ alert('Label update failed: ' + data.error); return false; } 9130 if(add){ if(gmailState.activeThreadLabels.indexOf(labelId) === -1) gmailState.activeThreadLabels.push(labelId); } 9131 else { gmailState.activeThreadLabels = gmailState.activeThreadLabels.filter(function(l){ return l !== labelId; }); } 9132 return true; 9133 }catch(e){ alert('Error: ' + e.message); return false; } 9134} 9135 9136async function openLabelPicker(threadId, anchorBtn){ 9137 var existing = ge('gmailLabelPicker'); 9138 if(existing){ existing.remove(); return; } 9139 var labels = await gmailLoadLabels(); 9140 var pop = document.createElement('div'); 9141 pop.id = 'gmailLabelPicker'; 9142 pop.style.cssText = 'position:absolute;z-index:50;background:var(--surface);border:1px solid var(--border);border-radius:8px;box-shadow:0 6px 24px rgba(0,0,0,0.3);padding:8px;max-height:260px;overflow-y:auto;min-width:200px;'; 9143 if(!labels.length){ 9144 var none = document.createElement('div'); 9145 none.style.cssText = 'padding:8px 10px;color:var(--text3);font-size:16px;'; 9146 none.textContent = 'No labels yet. Create them in Gmail first.'; 9147 pop.appendChild(none); 9148 } else { 9149 labels.forEach(function(lab){ 9150 var row = document.createElement('label'); 9151 row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 10px;cursor:pointer;font-size:16px;color:var(--text);border-radius:6px;'; 9152 var cb = document.createElement('input'); 9153 cb.type = 'checkbox'; 9154 cb.checked = gmailState.activeThreadLabels.indexOf(lab.id) !== -1; 9155 (function(labelId, checkbox){ 9156 checkbox.addEventListener('change', function(){ 9157 checkbox.disabled = true; 9158 gmailToggleLabel(threadId, labelId, checkbox.checked).then(function(ok){ 9159 checkbox.disabled = false; 9160 if(!ok) checkbox.checked = !checkbox.checked; 9161 }); 9162 }); 9163 })(lab.id, cb); 9164 var nm = document.createElement('span'); 9165 nm.textContent = lab.name; 9166 row.appendChild(cb); row.appendChild(nm); 9167 pop.appendChild(row); 9168 }); 9169 } 9170 document.body.appendChild(pop); 9171 var r = anchorBtn.getBoundingClientRect(); 9172 pop.style.top = (r.bottom + window.scrollY + 4) + 'px'; 9173 pop.style.left = (r.left + window.scrollX) + 'px'; 9174 setTimeout(function(){ 9175 document.addEventListener('click', function closePicker(e){ 9176 if(!pop.contains(e.target) && e.target !== anchorBtn){ 9177 pop.remove(); 9178 document.removeEventListener('click', closePicker); 9179 } 9180 }); 9181 }, 0); 9182} 9183 9184function renderThread(messages, threadId){ 9185 var thread = ge('gmailThread'); 9186 thread.innerHTML = ''; 9187 9188 // On mobile - hide sidebar and show thread 9189 var layout = ge('gmailLayout'); 9190 if(layout && window.innerWidth <= 768){ 9191 layout.classList.add('thread-open'); 9192 // Add back button at top 9193 var backBtn = document.createElement('button'); 9194 backBtn.style.cssText = 'display:flex;align-items:center;gap:6px;background:none;border:none;color:var(--accent);cursor:pointer;font-size:15px;font-family:DM Sans,sans-serif;padding:12px 16px;border-bottom:1px solid var(--border);width:100%;'; 9195 backBtn.innerHTML = '← Back to Inbox'; 9196 backBtn.addEventListener('click', function(){ 9197 layout.classList.remove('thread-open'); 9198 thread.innerHTML = '<div class="gmail-empty" id="gmailEmptyState"><svg width="36" height="36" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg><div>Select an email to read</div></div>'; 9199 }); 9200 thread.appendChild(backBtn); 9201 } 9202 9203 if(!messages.length){ thread.innerHTML='<div class="gmail-empty">Empty thread.</div>'; return; } 9204 9205 var firstMsg = messages[0]; 9206 gmailState.activeThreadLabels = []; 9207 (messages || []).forEach(function(m){ (m.labelIds || []).forEach(function(lid){ if(gmailState.activeThreadLabels.indexOf(lid) === -1) gmailState.activeThreadLabels.push(lid); }); }); 9208 var hdr = document.createElement('div'); 9209 hdr.className = 'gmail-thread-hdr';
9210 var subj = document.createElement('div'); 9211 subj.className = 'gmail-thread-subject'; 9212 subj.textContent = firstMsg.subject; 9213 9214 // Match contact 9215 var emailMatch = firstMsg.from.match(/<(.+)>/); 9216 var fromEmail = emailMatch ? emailMatch[1] : firstMsg.from; 9217 var contact = C.find(function(c){ return c.email && c.email.toLowerCase()===fromEmail.toLowerCase(); }); 9218 if(contact){ 9219 var cLink = document.createElement('div'); 9220 cLink.style.cssText = 'margin-top:6px;display:flex;align-items:center;gap:8px;'; 9221 var cBadge = document.createElement('span'); 9222 cBadge.className = 'tag tag-'+contact.type; 9223 cBadge.textContent = fn(contact); 9224 var cBtn = document.createElement('button'); 9225 cBtn.className = 'btn btn-g'; 9226 cBtn.style.cssText = 'font-size:18px;padding:3px 8px;'; 9227 cBtn.textContent = 'View Contact'; 9228 (function(cid){ cBtn.addEventListener('click', function(){ vc(cid); }); })(contact.id); 9229 cLink.appendChild(cBadge); cLink.appendChild(cBtn); 9230 hdr.appendChild(subj); hdr.appendChild(cLink); 9231 } else { 9232 // Option to create contact from email 9233 var newCBtn = document.createElement('button'); 9234 newCBtn.className = 'btn btn-g'; 9235 newCBtn.style.cssText = 'font-size:18px;padding:3px 8px;margin-top:6px;'; 9236 newCBtn.textContent = '+ Add as Contact'; 9237 (function(email, name){ newCBtn.addEventListener('click', function(){ 9238 var parts = name.replace(/<.*>/,'').trim().split(' '); 9239 ge('fFirst').value = parts[0]||''; 9240 ge('fLast').value = parts.slice(1).join(' ')||''; 9241 mvRender('fEmails', [{value:email,label:''}]); mvRender('fPhones',[]); mvRender('fAddresses',[]); ge('fWhatsapp').value=''; 9242 clearFTypeValues(); 9243 ge('btnSaveContact') && ge('btnSaveContact').setAttribute('data-edit-id',''); 9244 ge('addModalTitle') && (ge('addModalTitle').textContent='Add Contact'); 9245 ge('addModal').classList.add('open'); 9246 }); })(fromEmail, firstMsg.from); 9247 hdr.appendChild(subj); hdr.appendChild(newCBtn); 9248 } 9249 thread.appendChild(hdr); 9250 9251 // Action toolbar: archive / delete 9252 var gActions = document.createElement('div'); 9253 gActions.style.cssText = 'display:flex;gap:8px;padding:10px 20px;border-bottom:1px solid var(--border);flex-wrap:wrap;'; 9254 var gArchiveBtn = document.createElement('button'); 9255 gArchiveBtn.style.cssText = 'font-size:18px;padding:6px 14px;border:none;border-radius:7px;cursor:pointer;font-family:DM Sans,sans-serif;font-weight:600;background:var(--accent);color:#1a1612;'; 9256 gArchiveBtn.textContent = 'Archive'; 9257 (function(tid){ gArchiveBtn.addEventListener('click', function(){ gmailThreadAction('archive', tid); }); })(threadId); 9258 var gDelBtn = document.createElement('button'); 9259 gDelBtn.style.cssText = 'font-size:18px;padding:6px 14px;border:none;border-radius:7px;cursor:pointer;font-family:DM Sans,sans-serif;font-weight:600;background:var(--danger);color:#fff;'; 9260 gDelBtn.textContent = 'Delete'; 9261 (function(tid){ gDelBtn.addEventListener('click', function(){ if(confirm('Move this email to Trash?')) gmailThreadAction('trash', tid); }); })(threadId); 9262 var gLabelBtn = document.createElement('button'); 9263 gLabelBtn.style.cssText = 'font-size:18px;padding:6px 14px;border-radius:7px;cursor:pointer;font-family:DM Sans,sans-serif;font-weight:600;background:var(--surface2);color:var(--text);border:1px solid var(--border);'; 9264 gLabelBtn.textContent = 'Labels'; 9265 (function(tid){ gLabelBtn.addEventListener('click', function(e){ e.stopPropagation(); openLabelPicker(tid, gLabelBtn); }); })(threadId); 9266 gActions.appendChild(gLabelBtn); 9267 gActions.appendChild(gArchiveBtn); 9268 gActions.appendChild(gDelBtn); 9269 thread.appendChild(gActions); 9270 9271 // Messages 9272 var msgContainer = document.createElement('div'); 9273 msgContainer.style.flex = '1'; 9274 msgContainer.style.overflowY = 'auto'; 9275 9276 messages.forEach(function(msg){ 9277 var msgDiv = document.createElement('div'); 9278 msgDiv.className = 'gmail-message'; 9279 9280 var msgHdr = document.createElement('div'); 9281 msgHdr.className = 'gmail-message-hdr'; 9282 var fromDiv = document.createElement('div'); 9283 var fromName = document.createElement('div'); 9284 fromName.className = 'gmail-message-from'; 9285 fromName.textContent = msg.from; 9286 var toLine = document.createElement('div'); 9287 toLine.className = 'gmail-message-to'; 9288 toLine.textContent = 'To: ' + msg.to; 9289 fromDiv.appendChild(fromName); fromDiv.appendChild(toLine); 9290 var dateDiv = document.createElement('div'); 9291 dateDiv.className = 'gmail-message-date';
9292 dateDiv.textContent = formatGmailDate(msg.date); 9293 msgHdr.appendChild(fromDiv); msgHdr.appendChild(dateDiv); 9294 9295 // Body 9296 var bodyDiv = document.createElement('div'); 9297 bodyDiv.className = 'gmail-message-body'; 9298 if(msg.bodyHtml){ 9299 var iframe = document.createElement('iframe'); 9300 iframe.style.cssText = 'width:100%;border:none;background:white;border-radius:6px;min-height:100px;'; 9301 iframe.srcdoc = msg.bodyHtml; 9302 iframe.onload = function(){ try{ this.style.height = this.contentDocument.body.scrollHeight + 'px'; }catch(e){} }; 9303 bodyDiv.appendChild(iframe); 9304 } else { 9305 bodyDiv.textContent = msg.bodyText || msg.snippet || ''; 9306 } 9307 9308 // Attachments 9309 if(msg.attachments && msg.attachments.length){ 9310 var attDiv = document.createElement('div'); 9311 attDiv.style.cssText = 'margin-top:12px;border-top:1px solid var(--border);padding-top:10px;'; 9312 var attLabel = document.createElement('div'); 9313 attLabel.style.cssText = 'font-size:18px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text3);margin-bottom:6px;'; 9314 attLabel.textContent = 'Attachments'; 9315 attDiv.appendChild(attLabel); 9316 msg.attachments.forEach(function(att){ 9317 var attBtn = document.createElement('div'); 9318 attBtn.className = 'gmail-attachment'; 9319 attBtn.innerHTML = '<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14,2 14,8 20,8"/></svg>'; 9320 var attName = document.createElement('span'); 9321 attName.textContent = att.filename; 9322 attBtn.appendChild(attName); 9323 // Scan button for PDFs and images 9324 if(att.mimeType && (att.mimeType.includes('pdf') || att.mimeType.includes('image'))){ 9325 var scanBtn = document.createElement('button'); 9326 scanBtn.style.cssText = 'margin-left:6px;background:var(--accent);border:none;border-radius:4px;padding:2px 7px;font-size:18px;color:var(--bg);cursor:pointer;font-family:DM Sans,sans-serif;font-weight:700;'; 9327 scanBtn.textContent = 'Scan'; 9328 (function(a, mid, memberId){ scanBtn.addEventListener('click', function(e){ 9329 e.stopPropagation(); 9330 importAttachmentToScanner(memberId, mid, a.attachmentId, a.filename, a.mimeType); 9331 }); })(att, msg.id, gmailState.activeMemberId); 9332 attBtn.appendChild(scanBtn); 9333 } 9334 attDiv.appendChild(attBtn); 9335 }); 9336 msgDiv.appendChild(msgHdr); msgDiv.appendChild(bodyDiv); msgDiv.appendChild(attDiv); 9337 } else { 9338 msgDiv.appendChild(msgHdr); msgDiv.appendChild(bodyDiv); 9339 } 9340 msgContainer.appendChild(msgDiv); 9341 }); 9342 thread.appendChild(msgContainer); 9343 9344 // Reply bar 9345 var replyBar = document.createElement('div'); 9346 replyBar.className = 'gmail-compose'; 9347 var replyHdr = document.createElement('div'); 9348 replyHdr.className = 'gmail-compose-hdr'; 9349 var replyTitle = document.createElement('span'); 9350 replyTitle.textContent = 'Reply'; 9351 var replyExpand = document.createElement('button'); 9352 replyExpand.style.cssText = 'background:none;border:none;color:var(--text3);cursor:pointer;font-size:18px;font-family:DM Sans,sans-serif;'; 9353 replyExpand.textContent = 'Open in Composer'; 9354 var lastMsg = messages[messages.length-1]; 9355 (function(lm, tid){ replyExpand.addEventListener('click', function(){ 9356 openCompose({ 9357 to: lm.from, 9358 subject: 'Re: ' + lm.subject, 9359 threadId: tid, 9360 replyToMessageId: lm.id 9361 }); 9362 }); })(lastMsg, threadId); 9363 replyHdr.appendChild(replyTitle); replyHdr.appendChild(replyExpand); 9364 9365 var replyBody = document.createElement('div'); 9366 replyBody.className = 'gmail-compose-body'; 9367 var replyTA = document.createElement('textarea'); 9368 replyTA.className = 'fi'; 9369 replyTA.rows = 3; 9370 replyTA.style.resize = 'vertical'; 9371 replyTA.placeholder = 'Quick reply...'; 9372 var replySend = document.createElement('button'); 9373 replySend.className = 'btn btn-p'; 9374 replySend.style.cssText = 'margin-top:8px;font-size:18px;'; 9375 replySend.textContent = 'Send Reply'; 9376 (function(lm, tid, ta){ replySend.addEventListener('click', function(){ 9377 if(!ta.value.trim()){ alert('Please enter a reply.'); return; } 9378 sendEmail({ 9379 to: lm.from, 9380 subject: 'Re: ' + lm.subject, 9381 body: ta.value.trim().replace(/\n/g,'<br>'), 9382 threadId: tid, 9383 replyTo: lm.id 9384 }, function(){ ta.value=''; loadGmailInbox(); }); 9385 }); })(lastMsg, threadId, replyTA); 9386 replyBody.appendChild(replyTA); replyBody.appendChild(replySend);
9387 replyBar.appendChild(replyHdr); replyBar.appendChild(replyBody); 9388 thread.appendChild(replyBar); 9389} 9390 9391async function importAttachmentToScanner(memberId, messageId, attachmentId, filename, mimeType){ 9392 // memberId is captured when the email renders and passed in - do NOT read gmailState.activeMemberId 9393 // here, because navigating to the scanner for the first attachment can leave it stale, which made 9394 // a second attachment in the same email fail with a permission error until the email was re-opened. 9395 var mem = (memberId != null) ? memberId : gmailState.activeMemberId; 9396 sp('scanner'); 9397 ge('scannerUploadArea').style.display='none'; 9398 ge('scannerProcessing').style.display='block'; 9399 ge('scannerHistory').style.display='none'; 9400 ge('scannerProcessingMsg').textContent = 'Importing ' + filename + '...'; 9401 try{ 9402 var resp = await fetch('/api/gmail-api', { 9403 method:'POST', headers: await apiHeaders(), 9404 body: JSON.stringify({action:'attachment', memberId:mem, messageId:messageId, attachmentId:attachmentId}) 9405 }); 9406 var data = await resp.json(); 9407 if(data.error){ showScannerError(data.error); return; } 9408 // Create a fake file and start scan 9409 var binary = atob(data.data.replace(/-/g,'+').replace(/_/g,'/')); 9410 var bytes = new Uint8Array(binary.length); 9411 for(var i=0;i<binary.length;i++) bytes[i]=binary.charCodeAt(i); 9412 var blob = new Blob([bytes], {type: mimeType}); 9413 var file = new File([blob], filename, {type: mimeType}); 9414 startScan(file); 9415 }catch(e){ showScannerError('Import failed: '+e.message); } 9416} 9417 9418function openCompose(opts){ 9419 opts = opts||{}; 9420 ge('composeTitle').textContent = opts.replyToMessageId ? 'Reply' : 'New Email'; 9421 ge('composeTo').value = opts.to||''; 9422 ge('composeSubject').value = opts.subject||''; 9423 ge('composeBody').value = opts.body||''; 9424 // Populate from accounts 9425 var fromSel = ge('composeFrom'); 9426 fromSel.innerHTML = ''; 9427 Object.keys(gmailState.connectedAccounts).forEach(function(mid){ 9428 var acc = gmailState.connectedAccounts[mid]; 9429 var m = TM.find(function(x){ return String(x.id)===String(mid); }); 9430 var o = document.createElement('option'); 9431 o.value = mid; 9432 o.textContent = (m?m.first+' - ':'')+acc.email; 9433 fromSel.appendChild(o); 9434 }); 9435 if(gmailState.activeMemberId) fromSel.value = gmailState.activeMemberId; 9436 // Populate contact dropdown for merge fields 9437 var cmpCP = ge('composeLinkContactPick'); cmpCP.innerHTML = ''; 9438 buildContactPicker(cmpCP, 'composeLinkContact', 'Search contact (optional)...'); 9439 // Store reply info 9440 ge('composeModal').setAttribute('data-thread-id', opts.threadId||''); 9441 ge('composeModal').setAttribute('data-reply-to', opts.replyToMessageId||''); 9442 om('composeModal'); 9443} 9444 9445function applyTemplate(tplKey){ 9446 var tpl = EMAIL_TEMPLATES[tplKey]; 9447 if(!tpl) return; 9448 ge('composeSubject').value = tpl.subject; 9449 // Apply merge fields 9450 var body = tpl.body; 9451 var cid = ge('composeLinkContact').value; 9452 if(cid){ 9453 var c = gc(parseInt(cid)); 9454 if(c){ 9455 body = body.replace(/{{firstName}}/g, c.first||''); 9456 body = body.replace(/{{lastName}}/g, c.last||''); 9457 body = body.replace(/{{propertyAddress}}/g, c.property||''); 9458 var tx2 = TX.find(function(t){ return t.contactId===c.id; }); 9459 if(tx2) body = body.replace(/{{closingDate}}/g, tx2.closingDate ? fd(tx2.closingDate) : '[closing date]'); 9460 } 9461 } 9462 var mid = ge('composeFrom').value; 9463 var m = TM.find(function(x){ return String(x.id)===String(mid); }); 9464 if(m) body = body.replace(/{{agentName}}/g, m.first+' '+m.last); 9465 ge('composeBody').value = body; 9466} 9467 9468async function sendEmail(opts, callback){ 9469 var memberId = opts.memberId || gmailState.activeMemberId; 9470 if(!memberId){ alert('Please select a Gmail account to send from.'); return; } 9471 try{ 9472 var resp = await fetch('/api/gmail-api', { 9473 method:'POST', headers: await apiHeaders(), 9474 body: JSON.stringify({ 9475 action:'send', memberId:memberId, 9476 to:opts.to, subject:opts.subject, 9477 body:opts.body.replace(/\n/g,'<br>'), 9478 threadId:opts.threadId||null, 9479 replyTo:opts.replyTo||null 9480 }) 9481 }); 9482 var data = await resp.json(); 9483 if(data.error){ alert('Send failed: '+data.error); return; } 9484 if(callback) callback(); 9485 else { cm('composeModal'); alert('Email sent!'); loadGmailInbox(); } 9486 }catch(e){ alert('Send error: '+e.message); } 9487} 9488 9489async function sendComposedEmail(){ 9490 var to = ge('composeTo').value.trim(); 9491 var subject = ge('composeSubject').value.trim(); 9492 var body = ge('composeBody').value.trim(); 9493 var from = ge('composeFrom').value; 9494 if(!to||!subject||!body){ alert('Please fill in To, Subject, and Message.'); return; } 9495 if(!from){ alert('Please select which account to send from.'); return; } 9496 var threadId = ge('composeModal').getAttribute('data-thread-id')||null; 9497 var replyTo = ge('composeModal').getAttribute('data-reply-to')||null; 9498 // Save to contact's notes 9499 var cid = ge('composeLinkContact').value; 9500 if(cid){ 9501 var noteText = 'Email sent to ' + to + ': ' + subject; 9502 var nn={id:Date.now(),contactId:parseInt(cid),text:noteText,date:new Date().toISOString()}; 9503 N.push(nn); saveNote(nn); 9504 } 9505 ge('btnSendEmail').textContent='Sending...'; ge('btnSendEmail').disabled=true; 9506 await sendEmail({to,subject,body,memberId:from,threadId,replyTo}); 9507 ge('btnSendEmail').textContent='Send Email'; ge('btnSendEmail').disabled=false; 9508} 9509 9510// -- MLS SEARCH ------------------------------------------------------- 9511var mlsToken = ''; 9512var mlsLiveMode = false;
9513var mlsCurrentPage = 0; 9514var mlsPageSize = 12; 9515var mlsAllResults = []; 9516var mlsViewMode = 'grid'; 9517var mlsStatusFilter = 'active'; 9518 9519var MLS_DEMO = [ 9520 {id:'1001',address:'1842 E Millcreek Way',city:'Salt Lake City',state:'UT',zip:'84106',price:485000,beds:4,baths:2.5,sqft:2340,lot:'0.18 acres',dom:5,status:'Active',type:'Residential',year:1987,garage:2,desc:'Beautifully updated rambler in the heart of Millcreek. New kitchen with quartz countertops, stainless appliances. Finished basement with mother-in-law apartment potential. Large backyard with mature trees.',photo:'',listingNum:'URE-1001',agent:'Sarah Christensen',office:'Windermere Real Estate',lat:40.6882,lng:-111.8291}, 9521 {id:'1002',address:'3245 S Highland Dr',city:'Salt Lake City',state:'UT',zip:'84106',price:625000,beds:5,baths:3,sqft:3100,lot:'0.22 acres',dom:12,status:'Active',type:'Residential',year:1995,garage:2,desc:'Stunning two-story in Sugar House area. Grand entry, vaulted ceilings, gourmet kitchen. Master suite with spa bath. Deck overlooking professionally landscaped yard. Minutes from I-15.',photo:'',listingNum:'URE-1002',agent:'Mike Torres',office:'KW Salt Lake City',lat:40.7089,lng:-111.8647}, 9522 {id:'1003',address:'892 N 200 W',city:'Provo',state:'UT',zip:'84601',price:319000,beds:3,baths:2,sqft:1680,lot:'0.15 acres',dom:3,status:'Active',type:'Residential',year:1962,garage:1,desc:'Charming brick rambler near BYU. Updated kitchen and bathrooms. Hardwood floors throughout main level. Fully fenced yard. Close to shopping, schools, and freeway access.',photo:'',listingNum:'URE-1003',agent:'Jenny Park',office:'Equity Real Estate',lat:40.2338,lng:-111.6585}, 9523 {id:'1004',address:'547 E 300 S Unit 4B',city:'Salt Lake City',state:'UT',zip:'84102',price:289000,beds:2,baths:1,sqft:980,lot:'N/A',dom:18,status:'Active',type:'Condo',year:2005,garage:1,desc:'Modern downtown condo with city views. Open concept living, stainless kitchen, in-unit laundry. Building amenities include rooftop deck, gym, and secure parking.',photo:'',listingNum:'URE-1004',agent:'Dan Ellis',office:'Coldwell Banker',lat:40.7608,lng:-111.8910}, 9524 {id:'1005',address:'11234 S 700 E',city:'Sandy',state:'UT',zip:'84070',price:558000,beds:4,baths:3.5,sqft:2890,lot:'0.20 acres',dom:7,status:'Active',type:'Residential',year:2003,garage:3,desc:'Immaculate 2-story in desirable Sandy neighborhood. Updated throughout, granite counters, hardwood floors. Bonus room upstairs. 3-car garage. Walking distance to top-rated schools.',photo:'',listingNum:'URE-1005',agent:'Lisa Wang',office:'REMAX Advantage',lat:40.5915,lng:-111.8766}, 9525 {id:'1006',address:'2901 W 3500 S',city:'West Valley City',state:'UT',zip:'84119',price:399000,beds:4,baths:2,sqft:1950,lot:'0.19 acres',dom:22,status:'Active',type:'Residential',year:1978,garage:2,desc:'Great investment opportunity or family home. Updated bathrooms, new roof 2022, newer HVAC. Large lot with RV parking. Easy freeway access to downtown SLC.',photo:'',listingNum:'URE-1006',agent:'Carlos Mendez',office:'Century 21 Everest',lat:40.6963,lng:-111.9390}, 9526 {id:'1007',address:'445 N 100 E',city:'American Fork',state:'UT',zip:'84003',price:445000,beds:3,baths:2.5,sqft:2100,lot:'0.16 acres',dom:1,status:'Active',type:'Residential',year:2018,garage:2,desc:'Nearly new home in quiet cul-de-sac. Open concept floor plan, 9ft ceilings, smart home features. Quartz counters, farmhouse sink, mudroom. Stunning mountain views.',photo:'',listingNum:'URE-1007',agent:'Amy Johnson',office:'Utah Select Real Estate',lat:40.3774,lng:-111.7969}, 9527 {id:'1008',address:'7823 S 1300 E',city:'Midvale',state:'UT',zip:'84047',price:389000,beds:3,baths:2,sqft:1780,lot:'0.17 acres',dom:14,status:'Active',type:'Residential',year:1968,garage:2,desc:'Well-maintained brick home on tree-lined street. Kitchen remodeled 2021, newer windows and flooring. Basement family room, storage room, cold storage. Mature yard.',photo:'',listingNum:'URE-1008',agent:'Tom Bradley',office:'Summit Sothebys',lat:40.6185,lng:-111.8936}, 9528 {id:'1009',address:'156 W 800 N',city:'Orem',state:'UT',zip:'84057',price:349000,beds:3,baths:2,sqft:1560,lot:'0.12 acres',dom:9,status:'Active',type:'Residential',year:1982,garage:1,desc:'Move-in ready rancher near UVU and freeway. Updated kitchen, new carpet, fresh paint throughout. Covered patio, storage shed. Great starter home or investment.',photo:'',listingNum:'URE-1009',agent:'Rachel Davis',office:'Homie',lat:40.2969,lng:-111.6946}, 9529 {id:'1010',address:'3390 E 6200 S',city:'Cottonwood Heights',state:'UT',zip:'84121',price:729000,beds:5,baths:4,sqft:3650,lot:'0.28 acres',dom:4,status:'Active',type:'Residential',year:1999,garage:3,desc:'Spacious executive home at the base of the mountains. Chef kitchen with double ovens, center island. Master suite with fireplace and jetted tub. Walk-out basement. Minutes to ski resorts.',photo:'',listingNum:'URE-1010',agent:'Mark Stevens',office:'Windermere Real Estate',lat:40.6196,lng:-111.8102}, 9530 {id:'1011',address:'680 W Jordan Pkwy',city:'South Jordan',state:'UT',zip:'84095',price:519000,beds:4,baths:3,sqft:2700,lot:'0.21 acres',dom:11,status:'Pending',type:'Residential',year:2008,garage:2,desc:'Popular Daybreak community home. Open plan with great room concept. Formal dining, office/den. Master with soaking tub and walk-in closet. Central park and pool access.',photo:'',listingNum:'URE-1011',agent:'Stephanie Hill',office:'KW Salt Lake City',lat:40.5622,lng:-111.9296}, 9531 {id:'1012',address:'922 E 400 S Unit 12',city:'Salt Lake City',state:'UT',zip:'84102',price:274000,beds:1,baths:1,sqft:740,lot:'N/A',dom:30,status:'Pending',type:'Condo',year:1988,garage:1,desc:'Updated condo in vibrant 9th and 9th neighborhood. Bamboo floors, updated kitchen, private patio. HOA covers water, sewer, trash, exterior. Walk to shops and restaurants.',ph
9531oto:'',listingNum:'URE-1012',agent:'Jake Brown',office:'Urban Utah Homes',lat:40.7517,lng:-111.8786} 9532]; 9533 9534function initMLS(){ 9535 mlsAllResults = MLS_DEMO.slice(); 9536 renderMLSResults(); 9537 updateMLSTokenUI(); 9538} 9539 9540function updateMLSTokenUI(){ 9541 var banner = ge('mlsTokenBanner'); 9542 var clearBtn = ge('btnClearToken'); 9543 var saveBtn = ge('btnSaveToken'); 9544 if(mlsLiveMode){ 9545 banner.style.background = 'rgba(107,201,122,0.1)'; 9546 banner.style.borderColor = 'var(--lead)'; 9547 banner.querySelector('div').innerHTML = '<b style="color:var(--lead);">LIVE</b> - Connected to UtahRealEstate.com MLS API'; 9548 saveBtn.style.display = 'none'; 9549 clearBtn.style.display = 'inline-flex'; 9550 ge('mlsTokenInput').style.display = 'none'; 9551 } else { 9552 banner.style.background = 'rgba(201,168,76,0.1)'; 9553 banner.style.borderColor = 'var(--accent)'; 9554 saveBtn.style.display = 'inline-flex'; 9555 clearBtn.style.display = 'none'; 9556 ge('mlsTokenInput').style.display = 'inline-block'; 9557 } 9558} 9559 9560function connectMLSToken(){ 9561 var token = ge('mlsTokenInput').value.trim(); 9562 if(!token){ alert('Please paste your Bearer Token first.'); return; } 9563 mlsToken = token; 9564 mlsLiveMode = true; 9565 try{ localStorage.setItem('mls_token', token); }catch(e){} 9566 updateMLSTokenUI(); 9567 searchMLS(); 9568} 9569 9570function disconnectMLS(){ 9571 mlsToken = ''; 9572 mlsLiveMode = false; 9573 try{ localStorage.removeItem('mls_token'); }catch(e){} 9574 mlsAllResults = MLS_DEMO.slice(); 9575 updateMLSTokenUI(); 9576 renderMLSResults(); 9577} 9578 9579function searchMLS(){ 9580 if(mlsLiveMode){ 9581 fetchMLSLive(); 9582 } else { 9583 var city = ge('mlsCity').value.trim().toLowerCase(); 9584 var minP = parsePrice(ge('mlsMinPrice').value); 9585 var maxP = parsePrice(ge('mlsMaxPrice').value); 9586 var beds = ge('mlsBeds').value; 9587 var type = ge('mlsType').value; 9588 var results = MLS_DEMO.filter(function(l){ 9589 if(city && l.city.toLowerCase().indexOf(city) < 0 && l.zip.indexOf(city) < 0 && l.address.toLowerCase().indexOf(city) < 0) return false; 9590 if(minP && l.price < minP) return false; 9591 if(maxP && l.price > maxP) return false; 9592 if(beds && beds !== '5+' && l.beds < parseInt(beds)) return false; 9593 if(beds === '5+' && l.beds < 5) return false; 9594 if(type && l.type !== type) return false; 9595 if(mlsStatusFilter === 'active' && l.status !== 'Active') return false; 9596 if(mlsStatusFilter === 'pending' && l.status !== 'Pending') return false; 9597 return true; 9598 }); 9599 sortMLS(results); 9600 mlsAllResults = results; 9601 mlsCurrentPage = 0; 9602 renderMLSResults(); 9603 } 9604} 9605 9606function parsePrice(s){ 9607 if(!s) return 0; 9608 return parseInt(s.replace(/[$,]/g,'')) || 0; 9609} 9610 9611// ---- Commission helpers ---- 9612// Normalize a percent field to a number or null (blank/garbage -> null so numeric DB columns accept it). 9613function normPct(v){ 9614 v = (v==null ? '' : String(v)).trim().replace('%',''); 9615 if(v==='') return null; 9616 var n = parseFloat(v); 9617 return isNaN(n) ? null : n; 9618} 9619// Format a number as USD for display. 9620function fmtUSD(n){ 9621 if(n==null || isNaN(n)) return ''; 9622 return '$'+Number(n).toLocaleString('en-US',{minimumFractionDigits:0,maximumFractionDigits:2}); 9623} 9624// Compute a commission dollar amount from purchase price and percent. Base is purchase price, always. 9625function commissionAmt(price, pct){ 9626 var p = parsePrice(price); // handles "$450,000" strings and plain numbers 9627 var pc = parseFloat(pct); 9628 if(!p || isNaN(pc)) return null; 9629 return Math.round(p * pc / 100 * 100) / 100; // round to cents 9630} 9631// Recompute stored amounts from price + percent. When a percent is present the amount is always 9632// derived (decision: recompute on price/percent change). When percent is blank we leave the amount 9633// alone so a manually-entered flat fee (e.g. "$12,000" with no %) survives. 9634function recomputeCommission(tx){ 9635 if(!tx) return; 9636 if(tx.listCommissionPct != null && !isNaN(parseFloat(tx.listCommissionPct))){ 9637 tx.listCommissionAmt = commissionAmt(tx.price, tx.listCommissionPct); 9638 } 9639 if(tx.buyerCommissionPct != null && !isNaN(parseFloat(tx.buyerCommissionPct))){ 9640 tx.buyerCommissionAmt = commissionAmt(tx.price, tx.buyerCommissionPct); 9641 } 9642} 9643 9644function sortMLS(results){ 9645 var sort = ge('mlsSort').value; 9646 results.sort(function(a,b){ 9647 if(sort === 'price_asc') return a.price - b.price; 9648 if(sort === 'price_desc') return b.price - a.price; 9649 if(sort === 'dom_asc') return a.dom - b.dom; 9650 if(sort === 'dom_desc') return b.dom - a.dom;
9651 return 0; 9652 }); 9653} 9654 9655function fetchMLSLive(){ 9656 var city = ge('mlsCity').value.trim(); 9657 var minP = parsePrice(ge('mlsMinPrice').value); 9658 var maxP = parsePrice(ge('mlsMaxPrice').value); 9659 var beds = ge('mlsBeds').value; 9660 9661 var filters = []; 9662 var statusMap = {active:'Active',pending:'Pending',sold:'Closed'}; 9663 var statusVal = statusMap[mlsStatusFilter] || 'Active'; 9664 filters.push('StandardStatus eq ' + statusVal); 9665 if(city) filters.push('(City eq ' + city + ' or PostalCode eq ' + city + ')'); 9666 if(minP) filters.push('ListPrice ge ' + minP); 9667 if(maxP) filters.push('ListPrice le ' + maxP); 9668 if(beds && beds !== '') filters.push('BedroomsTotal ge ' + parseInt(beds)); 9669 9670 var url = 'https://resoapi.utahrealestate.com/reso/odata/Property?$top=50&$orderby=ModificationTimestamp desc'; 9671 if(filters.length) url += '&$filter=' + encodeURIComponent(filters.join(' and ')); 9672 url += '&$select=ListingKeyNumeric,ListPrice,StreetNumber,StreetName,StreetSuffix,City,StateOrProvince,PostalCode,BedroomsTotal,BathroomsTotalInteger,LivingArea,LotSizeArea,DaysOnMarket,StandardStatus,PropertyType,YearBuilt,GarageSpaces,PublicRemarks,ListAgentFullName,ListOfficeName'; 9673 9674 ge('mlsResults').innerHTML = '<div class="empty">Searching MLS...</div>'; 9675 9676 fetch(url, {headers:{Authorization:'Bearer '+mlsToken, Accept:'application/json'}}) 9677 .then(function(r){ 9678 return r.text().then(function(txt){ 9679 if(!r.ok){ 9680 if(r.status === 413 || /too large|request entity/i.test(txt)){ 9681 throw new Error('That file is too large to scan. Try a single page or a photo of the page.'); 9682 } 9683 throw new Error('Scan server error ' + r.status + ': ' + txt.substring(0,120)); 9684 } 9685 try{ return JSON.parse(txt); } 9686 catch(e){ throw new Error('The scan server did not return a valid response. ' + txt.substring(0,120)); } 9687 }); 9688 }) 9689 .then(function(data){ 9690 if(!data.value){ ge('mlsResults').innerHTML = '<div class="empty">No results. Check your token or search criteria.</div>'; return; } 9691 mlsAllResults = data.value.map(function(p){ 9692 return { 9693 id: String(p.ListingKeyNumeric), 9694 address: (p.StreetNumber||'') + ' ' + (p.StreetName||'') + ' ' + (p.StreetSuffix||''), 9695 city: p.City || '', state: p.StateOrProvince || 'UT', zip: p.PostalCode || '', 9696 price: p.ListPrice || 0, beds: p.BedroomsTotal || 0, baths: p.BathroomsTotalInteger || 0, 9697 sqft: p.LivingArea || 0, lot: p.LotSizeArea ? p.LotSizeArea + ' acres' : 'N/A', 9698 dom: p.DaysOnMarket || 0, status: p.StandardStatus || '', 9699 type: p.PropertyType || '', year: p.YearBuilt || '', 9700 garage: p.GarageSpaces || 0, desc: p.PublicRemarks || '', 9701 photo: '', listingNum: 'URE-'+p.ListingKeyNumeric, 9702 agent: p.ListAgentFullName || '', office: p.ListOfficeName || '' 9703 }; 9704 }); 9705 mlsCurrentPage = 0; 9706 renderMLSResults(); 9707 }) 9708 .catch(function(err){ 9709 ge('mlsResults').innerHTML = '<div class="empty" style="color:var(--danger);">API error: ' + err.message + '<br>Check your Bearer Token and try again.</div>'; 9710 }); 9711} 9712 9713function renderMLSResults(){ 9714 var total = mlsAllResults.length; 9715 var start = mlsCurrentPage * mlsPageSize; 9716 var page = mlsAllResults.slice(start, start + mlsPageSize); 9717 ge('mlsResultCount').textContent = total + ' listing' + (total !== 1 ? 's' : '') + ' found' + (mlsLiveMode ? ' (Live MLS)' : ' (Demo)'); 9718 var res = ge('mlsResults'); 9719 res.innerHTML = ''; 9720 if(!total){ res.innerHTML = '<div class="empty">No listings match your search. Try adjusting the filters.</div>'; ge('mlsPagination').innerHTML=''; return; } 9721 if(mlsViewMode === 'grid'){ 9722 var grid = document.createElement('div'); 9723 grid.className = 'mls-grid'; 9724 page.forEach(function(l){ grid.appendChild(mkMLSCard(l)); }); 9725 res.appendChild(grid); 9726 } else { 9727 page.forEach(function(l){ res.appendChild(mkMLSListItem(l)); }); 9728 } 9729 renderMLSPagination(total); 9730} 9731 9732function fmtPrice(p){ return '$' + p.toLocaleString(); } 9733function fmtSqft(s){ return s ? s.toLocaleString() + ' sqft' : 'N/A'; } 9734function domClass(d){ return d <= 7 ? 'dom-new' : d <= 30 ? 'dom-mid' : 'dom-old'; } 9735function domLabel(d){ return d === 0 ? 'NEW' : d + 'd'; } 9736 9737function mkMLSCard(l){ 9738 var card = document.createElement('div'); 9739 card.className = 'mls-card'; 9740 (function(listing){ card.addEventListener('click', function(){ openMLSDetail(listing); }); })(l); 9741 9742 var photoDiv = document.createElement('div'); 9743 photoDiv.className = 'mls-photo'; 9744 photoDiv.style.cssText = 'width:100%;height:170px;background:var(--surface2);display:flex;align-items:center;justify-content:center;position:relative;';
9745 var photoPlaceholder = document.createElement('div'); 9746 photoPlaceholder.style.cssText = 'text-align:center;color:var(--text3);'; 9747 photoPlaceholder.innerHTML = '<svg width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9,22 9,12 15,12 15,22"/></svg><div style="font-size:18px;margin-top:4px;">No Photo</div>'; 9748 photoDiv.appendChild(photoPlaceholder); 9749 9750 var domBadge = document.createElement('div'); 9751 domBadge.className = 'mls-dom ' + domClass(l.dom); 9752 domBadge.style.cssText = 'position:absolute;top:8px;right:8px;font-size:18px;font-family:monospace;padding:2px 7px;border-radius:4px;font-weight:700;'; 9753 domBadge.textContent = domLabel(l.dom); 9754 photoDiv.appendChild(domBadge); 9755 9756 var statusBadge = document.createElement('div'); 9757 statusBadge.style.cssText = 'position:absolute;top:8px;left:8px;font-size:18px;font-weight:700;padding:2px 7px;border-radius:4px;text-transform:uppercase;'; 9758 statusBadge.style.background = l.status === 'Active' ? 'rgba(107,201,122,0.9)' : 'rgba(201,168,76,0.9)'; 9759 statusBadge.style.color = '#0d0f14'; 9760 statusBadge.textContent = l.status; 9761 photoDiv.appendChild(statusBadge); 9762 card.appendChild(photoDiv); 9763 9764 var body = document.createElement('div'); 9765 body.className = 'mls-card-body'; 9766 var price = document.createElement('div'); 9767 price.className = 'mls-price'; 9768 price.textContent = fmtPrice(l.price); 9769 var addr = document.createElement('div'); 9770 addr.className = 'mls-addr'; 9771 addr.textContent = l.address; 9772 var sub = document.createElement('div'); 9773 sub.className = 'mls-sub'; 9774 sub.textContent = l.city + ', ' + l.state + ' ' + l.zip; 9775 var stats = document.createElement('div'); 9776 stats.className = 'mls-stats'; 9777 stats.innerHTML = '<span class="mls-stat"><b>' + l.beds + '</b> bd</span>' 9778 + '<span class="mls-stat"><b>' + l.baths + '</b> ba</span>' 9779 + '<span class="mls-stat"><b>' + (l.sqft ? l.sqft.toLocaleString() : 'N/A') + '</b> sqft</span>'; 9780 body.appendChild(price); body.appendChild(addr); body.appendChild(sub); body.appendChild(stats); 9781 card.appendChild(body); 9782 9783 var footer = document.createElement('div'); 9784 footer.className = 'mls-card-footer'; 9785 var attachBtn = document.createElement('button'); 9786 attachBtn.className = 'btn btn-g'; 9787 attachBtn.style.cssText = 'font-size:18px;padding:4px 9px;'; 9788 attachBtn.textContent = 'Attach to Client'; 9789 (function(listing){ attachBtn.addEventListener('click', function(e){ e.stopPropagation(); openAttachModal(listing); }); })(l); 9790 var detailBtn = document.createElement('button'); 9791 detailBtn.className = 'btn btn-p'; 9792 detailBtn.style.cssText = 'font-size:18px;padding:4px 9px;margin-left:auto;'; 9793 detailBtn.textContent = 'View Details'; 9794 (function(listing){ detailBtn.addEventListener('click', function(e){ e.stopPropagation(); openMLSDetail(listing); }); })(l); 9795 footer.appendChild(attachBtn); footer.appendChild(detailBtn); 9796 card.appendChild(footer); 9797 return card; 9798} 9799 9800function mkMLSListItem(l){ 9801 var item = document.createElement('div'); 9802 item.className = 'mls-list-item'; 9803 (function(listing){ item.addEventListener('click', function(){ openMLSDetail(listing); }); })(l); 9804 9805 var thumb = document.createElement('div'); 9806 thumb.className = 'mls-list-thumb'; 9807 thumb.innerHTML = '<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>'; 9808 9809 var info = document.createElement('div'); 9810 info.style.flex = '1'; 9811 var price = document.createElement('div'); 9812 price.style.cssText = 'font-family:Bebas Neue,sans-serif;font-size:18px;color:var(--accent);'; 9813 price.textContent = fmtPrice(l.price); 9814 var addr = document.createElement('div'); 9815 addr.style.cssText = 'font-size:18px;font-weight:600;color:var(--text);'; 9816 addr.textContent = l.address + ', ' + l.city + ' ' + l.zip; 9817 var stats = document.createElement('div'); 9818 stats.style.cssText = 'font-size:18px;color:var(--text2);margin-top:3px;'; 9819 stats.textContent = l.beds + ' bd | ' + l.baths + ' ba | ' + fmtSqft(l.sqft) + ' | ' + domLabel(l.dom) + ' on market'; 9820 info.appendChild(price); info.appendChild(addr); info.appendChild(stats); 9821 9822 var right = document.createElement('div'); 9823 right.style.cssText = 'display:flex;flex-direction:column;gap:6px;align-items:flex-end;flex-shrink:0;'; 9824 var domDiv = document.createElement('div'); 9825 domDiv.className = 'mls-dom ' + domClass(l.dom); 9826 domDiv.textContent = domLabel(l.dom); 9827 var attachBtn = document.createElement('button'); 9828 attachBtn.className = 'btn btn-g'; 9829 attachBtn.style.cssText = 'font-size:18px;padding:4px 9px;'; 9830 attachBtn.textContent = 'Attach'; 9831 (function(listing){ attachBtn.addEventListener('click', function(e){ e.stopPropagation(); openAttachModal(listing); }); })(l); 9832 right.appendChild(domDiv); right.appendChild(attachBtn); 9833 9834 item.appendChild(thumb); item.appendChild(info); item.appendChild(right); 9835 return item; 9836} 9837 9838function renderMLSPagination(total){ 9839 var pages = Math.ceil(total / mlsPageSize); 9840 var pag = ge('mlsPagination'); 9841 pag.innerHTML = ''; 9842 if(pages <= 1) return; 9843 for(var i = 0; i < pages; i++){ 9844 var btn = document.createElement('button'); 9845 btn.style.cssText = 'padding:5px 11px;border-radius:5px;border:1px solid var(--border);cursor:pointer;font-size:18px;font-family:DM Sans,sans-serif;transition:all 0.15s;'; 9846 btn.style.background = i === mlsCurrentPage ? 'var(--accent)' : 'var(--surface2)'; 9847 btn.style.color = i === mlsCurrentPage ? 'var(--bg)' : 'var(--text2)'; 9848 btn.textContent = i + 1; 9849 (function(page){ btn.addEventListener('click', function(){ mlsCurrentPage = page; renderMLSResults(); window.scrollTo(0,0); }); })(i); 9850 pag.appendChild(btn); 9851 } 9852} 9853 9854function openMLSDetail(l){ 9855 var content = ge('mlsDetContent'); 9856 content.innerHTML = ''; 9857 9858 var photoSection = document.createElement('div'); 9859 photoSection.className = 'mls-det-photos'; 9860 photoSection.innerHTML = '<div style="width:100%;height:240px;background:var(--surface2);display:flex;align-items:center;justify-content:center;flex-direction:column;color:var(--text3);"><svg width="48" height="48" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24">
9860<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9,22 9,12 15,12 15,22"/></svg><div style="margin-top:8px;font-size:18px;">Photos load with live MLS token</div></div>'; 9861 content.appendChild(photoSection); 9862 9863 var body = document.createElement('div'); 9864 body.className = 'mls-det-body'; 9865 9866 var price = document.createElement('div'); 9867 price.className = 'mls-det-price'; 9868 price.textContent = fmtPrice(l.price); 9869 9870 var addr = document.createElement('div'); 9871 addr.className = 'mls-det-addr'; 9872 addr.textContent = l.address; 9873 9874 var sub = document.createElement('div'); 9875 sub.className = 'mls-det-sub'; 9876 sub.textContent = l.city + ', ' + l.state + ' ' + l.zip + ' | MLS# ' + l.listingNum; 9877 9878 var statsGrid = document.createElement('div'); 9879 statsGrid.className = 'mls-det-stats'; 9880 [['Beds', l.beds], ['Baths', l.baths], ['Sqft', l.sqft ? l.sqft.toLocaleString() : 'N/A'], ['DOM', domLabel(l.dom)]].forEach(function(s){ 9881 var stat = document.createElement('div'); 9882 stat.className = 'mls-det-stat'; 9883 stat.innerHTML = '<div class="mls-det-stat-val">' + s[1] + '</div><div class="mls-det-stat-lbl">' + s[0] + '</div>'; 9884 statsGrid.appendChild(stat); 9885 }); 9886 9887 var detailsSec = document.createElement('div'); 9888 detailsSec.className = 'mls-det-section'; 9889 var detTitle = document.createElement('div'); 9890 detTitle.className = 'mls-det-sec-title'; 9891 detTitle.textContent = 'Property Details'; 9892 var detGrid = document.createElement('div'); 9893 detGrid.className = 'mls-det-grid'; 9894 [['Type', l.type], ['Year Built', l.year], ['Garage', l.garage + ' car'], ['Lot Size', l.lot], ['Status', l.status], ['Price/sqft', l.sqft ? '$'+Math.round(l.price/l.sqft) : 'N/A']].forEach(function(f){ 9895 var field = document.createElement('div'); 9896 field.className = 'mls-det-field'; 9897 field.innerHTML = f[0] + ': <b>' + f[1] + '</b>'; 9898 detGrid.appendChild(field); 9899 }); 9900 detailsSec.appendChild(detTitle); detailsSec.appendChild(detGrid); 9901 9902 var descSec = document.createElement('div'); 9903 descSec.className = 'mls-det-section'; 9904 var descTitle = document.createElement('div'); 9905 descTitle.className = 'mls-det-sec-title'; 9906 descTitle.textContent = 'Description'; 9907 var descText = document.createElement('div'); 9908 descText.style.cssText = 'font-size:18px;color:var(--text2);line-height:1.6;'; 9909 descText.textContent = l.desc || 'No description available.'; 9910 descSec.appendChild(descTitle); descSec.appendChild(descText); 9911 9912 var agentSec = document.createElement('div'); 9913 agentSec.className = 'mls-det-section'; 9914 var agentTitle = document.createElement('div'); 9915 agentTitle.className = 'mls-det-sec-title'; 9916 agentTitle.textContent = 'Listing Agent'; 9917 var agentText = document.createElement('div'); 9918 agentText.style.cssText = 'font-size:18px;color:var(--text2);';
9919 agentText.textContent = (l.agent || 'N/A') + (l.office ? ' | ' + l.office : ''); 9920 agentSec.appendChild(agentTitle); agentSec.appendChild(agentText); 9921 9922 var actions = document.createElement('div'); 9923 actions.style.cssText = 'display:flex;gap:9px;flex-wrap:wrap;margin-top:18px;padding-top:16px;border-top:1px solid var(--border);'; 9924 var attachBtn = document.createElement('button'); 9925 attachBtn.className = 'btn btn-p'; 9926 attachBtn.textContent = 'Attach to Client'; 9927 (function(listing){ attachBtn.addEventListener('click', function(){ openAttachModal(listing); }); })(l); 9928 var shareBtn = document.createElement('button'); 9929 shareBtn.className = 'btn btn-g'; 9930 shareBtn.textContent = 'Copy Listing #'; 9931 shareBtn.addEventListener('click', function(){ navigator.clipboard && navigator.clipboard.writeText(l.listingNum); shareBtn.textContent = 'Copied!'; setTimeout(function(){ shareBtn.textContent = 'Copy Listing #'; }, 2000); }); 9932 actions.appendChild(attachBtn); actions.appendChild(shareBtn); 9933 9934 body.appendChild(price); body.appendChild(addr); body.appendChild(sub); 9935 body.appendChild(statsGrid); body.appendChild(detailsSec); 9936 body.appendChild(descSec); body.appendChild(agentSec); body.appendChild(actions); 9937 content.appendChild(body); 9938 ge('mlsDetOv').classList.add('open'); 9939} 9940 9941function openAttachModal(listing){ 9942 var modal = document.createElement('div'); 9943 modal.className = 'modal-ov open'; 9944 modal.style.zIndex = '1100'; 9945 var inner = document.createElement('div'); 9946 inner.className = 'modal'; 9947 inner.style.maxWidth = '420px'; 9948 var hdr = document.createElement('div'); 9949 hdr.className = 'mhdr'; 9950 hdr.innerHTML = '<div class="mtitle">Attach to Client</div>'; 9951 var x = document.createElement('button'); 9952 x.className = 'mx'; 9953 x.textContent = 'x'; 9954 x.addEventListener('click', function(){ document.body.removeChild(modal); }); 9955 hdr.appendChild(x); 9956 var bod = document.createElement('div'); 9957 bod.className = 'mbody'; 9958 var info = document.createElement('p'); 9959 info.style.cssText = 'font-size:18px;color:var(--text2);margin-bottom:14px;'; 9960 info.textContent = 'Attach ' + listing.address + ' (' + fmtPrice(listing.price) + ') to:'; 9961 var selLabel = document.createElement('label'); 9962 selLabel.className = 'fl'; 9963 selLabel.textContent = 'Select Client'; 9964 var sel = document.createElement('select'); 9965 sel.className = 'fsel'; 9966 C.filter(function(c){ var t=ctypes(c); return t.indexOf('buyer')>=0||t.indexOf('prospect')>=0||t.indexOf('lead')>=0; }).forEach(function(c){ 9967 var opt = document.createElement('option'); 9968 opt.value = c.id; 9969 opt.textContent = fn(c) + ' (' + c.type + ')'; 9970 sel.appendChild(opt); 9971 }); 9972 bod.appendChild(info); bod.appendChild(selLabel); bod.appendChild(sel); 9973 var ftr = document.createElement('div'); 9974 ftr.className = 'mftr'; 9975 var cancelBtn = document.createElement('button'); 9976 cancelBtn.className = 'btn btn-g'; 9977 cancelBtn.textContent = 'Cancel'; 9978 cancelBtn.addEventListener('click', function(){ document.body.removeChild(modal); }); 9979 var saveBtn = document.createElement('button'); 9980 saveBtn.className = 'btn btn-p'; 9981 saveBtn.textContent = 'Attach Listing'; 9982 saveBtn.addEventListener('click', function(){ 9983 var cid = parseInt(sel.value); 9984 var note = 'Interested listing: ' + listing.address + ', ' + listing.city + ' ' + listing.zip + ' | ' + fmtPrice(listing.price) + ' | ' + listing.beds + 'bd/' + listing.baths + 'ba | ' + fmtSqft(listing.sqft) + ' | MLS# ' + listing.listingNum; 9985 N.push({id:Date.now(), contactId:cid, text:note, date:new Date().toISOString()}); 9986 sv(); 9987 document.body.removeChild(modal); 9988 alert('Listing attached to ' + (fn(gc(cid))||'client') + ' as a note!'); 9989 }); 9990 ftr.appendChild(cancelBtn); ftr.appendChild(saveBtn); 9991 inner.appendChild(hdr); inner.appendChild(bod); inner.appendChild(ftr); 9992 modal.appendChild(inner); 9993 modal.addEventListener('click', function(e){ if(e.target === modal) document.body.removeChild(modal); }); 9994 document.body.appendChild(modal); 9995} 9996 9997ge('nav-briefing').addEventListener('click',function(){sp('briefing');}); 9998ge('nav-tc').addEventListener('click',function(){sp('tc');}); 9999ge('nav-scanner').addEventListener('click',function(){sp('scanner');}); 10000ge('nav-cardscanner').addEventListener('click',function(){sp('cardscanner');}); 10001ge('nav-calendar').addEventListener('click',function(){sp('calendar');}); 10002ge('nav-team').addEventListener('click',function(){sp('team');}); 10003ge('nav-gmail').addEventListener('click',function(){sp('gmail');}); 10004(function(){
10005 var rez = ge('gmailResizer'), lay = ge('gmailLayout'); 10006 if(!rez || !lay) return; 10007 var dragging = false; 10008 rez.addEventListener('mousedown', function(e){ dragging = true; document.body.style.userSelect='none'; e.preventDefault(); }); 10009 document.addEventListener('mousemove', function(e){ 10010 if(!dragging) return; 10011 var rect = lay.getBoundingClientRect(); 10012 var w = e.clientX - rect.left; 10013 if(w < 220) w = 220; 10014 if(w > rect.width - 300) w = rect.width - 300; 10015 lay.style.setProperty('--gmail-list-w', w + 'px'); 10016 }); 10017 document.addEventListener('mouseup', function(){ if(dragging){ dragging=false; document.body.style.userSelect=''; } }); 10018})(); 10019// Auto-refresh the Gmail inbox while viewing it (silent, every 2 min) + on window focus 10020function gmailAutoRefresh(){ 10021 if(curPage!=='gmail' || !gmailState.activeMemberId) return; 10022 var sb=ge('searchBox'); if(sb && sb.value.trim()) return; // don't disrupt an active search 10023 gmailState.silentRefresh = true; 10024 loadGmailInbox(); 10025 gmailState.silentRefresh = false; 10026} 10027setInterval(gmailAutoRefresh, 120000); 10028window.addEventListener('focus', gmailAutoRefresh); 10029ge('nav-drips').addEventListener('click',function(){sp('drips');}); 10030(function(){ var ni=ge('nav-investments'); if(ni) ni.addEventListener('click',function(){sp('investments');}); })(); 10031(function(){ var nh=ge('nav-hardmoney'); if(nh) nh.addEventListener('click',function(){sp('hardmoney');}); })(); 10032(function(){ var nd=ge('nav-dealsheet'); if(nd) nd.addEventListener('click',function(){sp('dealsheet');}); })(); 10033(function(){ var nl=ge('nav-loi'); if(nl) nl.addEventListener('click',function(){sp('loi');}); })(); 10034(function(){ var nl=ge('nav-listings'); if(nl) nl.addEventListener('click',function(){sp('listings');}); })(); 10035(function(){ var nl=ge('nav-commercial'); if(nl) nl.addEventListener('click',function(){sp('commercial');}); })(); 10036(function(){ var nl=ge('nav-newconstruction'); if(nl) nl.addEventListener('click',function(){sp('newconstruction');}); })(); 10037 10038ge('nav-documents').addEventListener('click',function(){sp('documents');}); 10039if(ge('docSearch')) ge('docSearch').addEventListener('input', renderDocsPage); 10040if(ge('docTypeFilter')) ge('docTypeFilter').addEventListener('change', renderDocsPage); 10041if(ge('docClear')) ge('docClear').addEventListener('click', function(){ ge('docSearch').value=''; ge('docTypeFilter').value=''; if(ge('docShowArchived')) ge('docShowArchived').checked=false; selectedDocs.clear(); renderDocsPage(); }); 10042if(ge('docShowArchived')) ge('docShowArchived').addEventListener('change', function(){ selectedDocs.clear(); renderDocsPage(); }); 10043 10044// ---- Reminder settings ---- 10045if(ge('btnReminderSettings')) ge('btnReminderSettings').addEventListener('click', openReminderSettings); 10046if(ge('btnSaveRS')) ge('btnSaveRS').addEventListener('click', saveReminderSettings); 10047if(ge('rsAddType')) ge('rsAddType').addEventListener('click', rsAddType); 10048// ---- Deadlines subtabs + personal reminders ---- 10049if(ge('dlTabTx')) ge('dlTabTx').addEventListener('click', function(){ dlShowView('transactions'); }); 10050if(ge('dlTabPersonal')) ge('dlTabPersonal').addEventListener('click', function(){ dlShowView('personal'); }); 10051if(ge('btnAddPR')) ge('btnAddPR').addEventListener('click', function(){ openPersonalReminder(''); }); 10052if(ge('btnSavePR')) ge('btnSavePR').addEventListener('click', savePersonalReminder); 10053// ---- Ask-CRM voice assistant ---- 10054// ---- Reusable delete-confirmation dialog ---- 10055if(ge('delConfirmBtn')) ge('delConfirmBtn').addEventListener('click', function(){ 10056 var del = {}; 10057 document.querySelectorAll('#delCascadeWrap [data-cascade]').forEach(function(cb){ 10058 if(cb.checked) del[cb.getAttribute('data-cascade')] = true; // checked = delete; unchecked = keep 10059 }); 10060 cm('delModal'); 10061 var fn2 = _delOnConfirm; _delOnConfirm = null; 10062 if(typeof fn2 === 'function') fn2(del); 10063}); 10064 10065if(ge('askFab')) ge('askFab').addEventListener('click', openAskModal); 10066if(ge('askMic')) ge('askMic').addEventListener('click', askListen); 10067if(ge('askGo')) ge('askGo').addEventListener('click', function(){ askCrm(ge('askInput').value); }); 10068if(ge('askInput')) ge('askInput').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); askCrm(this.value); } }); 10069if(ge('askReplay')) ge('askReplay').addEventListener('click', function(){ if(lastAskAnswer) askSpeak(lastAskAnswer); }); 10070if(ge('askStop')) ge('askStop').addEventListener('click', askStopSpeak); 10071if(ge('askEmail')) ge('askEmail').addEventListener('click', function(){ 10072 if(!lastAskAnswer){ return; } 10073 if(typeof gmailState==='undefined' || !Object.keys(gmailState.connectedAccounts||{}
10073).length){ alert('Connect a Gmail account first (Gmail tab) to email answers.'); return; } 10074 var myEmail = (currentUser && currentUser.email) ? currentUser.email : ''; 10075 cm('askModal'); askStopSpeak(); 10076 openCompose({ to: myEmail, subject: 'From your CRM assistant', body: lastAskAnswer }); 10077}); 10078 10079// ---- Import past sales ---- 10080if(ge('btnImportPastSales')) ge('btnImportPastSales').addEventListener('click', openPastSalesImport); 10081if(ge('psFile')) ge('psFile').addEventListener('change', function(){ psLoadFile(this.files && this.files[0]); }); 10082if(ge('btnCommitPS')) ge('btnCommitPS').addEventListener('click', commitPastSalesImport); 10083if(ge('rsNewType')) ge('rsNewType').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); rsAddType(); } }); 10084 10085// Trigger the daily reminder job on demand. Sends your Supabase session token, which the 10086// endpoint accepts as an alternative to CRON_SECRET - so the secret stays server-side. 10087async function runRemindersNow(){ 10088 if(!confirm( 10089 'Send any reminders that are due right now?\n\n' 10090 + 'This runs the same job as the 7am automatic one - it emails clients whose deadlines ' 10091 + 'fall on your reminder days, and Ccs the team.\n\n' 10092 + 'Reminders already sent will NOT be sent again, and closed deals are skipped. ' 10093 + 'The only difference from waiting is that today\'s reminders go out now instead of at 7am.' 10094 )) return; 10095 var btn = ge('btnRunReminders'); 10096 var old = btn ? btn.textContent : ''; 10097 if(btn){ btn.disabled = true; btn.textContent = 'Running...'; } 10098 try{ 10099 var h = await getAuthHeaders(); 10100 var resp = await fetch('/api/run-drips', { headers: h }); 10101 var txt = await resp.text(); 10102 var data; 10103 try{ data = JSON.parse(txt); } 10104 catch(e){ alert('Unexpected response (' + resp.status + '):\n' + txt.substring(0,300)); return; } 10105 if(data.error){ alert('Error: ' + data.error); return; } 10106 var r = (data.result && data.result.reminders) || {}; 10107 var d2 = (data.result && data.result.drips) || {}; 10108 var msg = 'REMINDERS\n' 10109 + ' deadlines checked: ' + (r.checked||0) + '\n' 10110 + ' emails sent: ' + (r.sent||0) + '\n' 10111 + ' skipped (already sent, or closed deal): ' + (r.skipped||0) + '\n' 10112 + ' failed: ' + (r.failed||0) + '\n\n' 10113 + 'DRIP STEPS SENT: ' + (d2.sent||0); 10114 var errs = (r.errors||[]).concat(d2.errors||[]); 10115 if(errs.length) msg += '\n\nErrors:\n- ' + errs.slice(0,6).join('\n- '); 10116 if(!(r.checked||0) && !(r.sent||0)) msg += '\n\nNothing checked means no deadlines fall on your reminder days right now.'; 10117 alert(msg); 10118 }catch(e){ alert('Could not run: ' + e.message); } 10119 finally{ if(btn){ btn.disabled = false; btn.textContent = old || 'Send Reminders Now'; } } 10120} 10121if(ge('btnRunReminders')) ge('btnRunReminders').addEventListener('click', runRemindersNow); 10122 10123function renderDocsPage(){ 10124 var listEl = ge('docsList'); 10125 if(!listEl) return; 10126 var typeSel = ge('docTypeFilter'); 10127 if(typeSel){ 10128 var curType = typeSel.value; 10129 var types = []; 10130 DOCS.forEach(function(d){ if(d.doc_type && types.indexOf(d.doc_type)===-1) types.push(d.doc_type); }); 10131 types.sort(); 10132 typeSel.innerHTML = ''; 10133 var allOpt = document.createElement('option'); allOpt.value=''; allOpt.textContent='All Types'; typeSel.appendChild(allOpt); 10134 types.forEach(function(t){ var o=document.createElement('option'); o.value=t; o.textContent=t; typeSel.appendChild(o); }); 10135 typeSel.value = curType; 10136 } 10137 var q = ((ge('docSearch') && ge('docSearch').value) || '').trim().toLowerCase(); 10138 var typeF = (ge('docTypeFilter') && ge('docTypeFilter').value) || ''; 10139 var showArch = !!(ge('docShowArchived') && ge('docShowArchived').checked); 10140 var rows = DOCS.slice().sort(function(a,b){ return (b.id||0) - (a.id||0); }); 10141 var archCount = rows.filter(docIsArchived).length; 10142 var filtered = rows.filter(function(d){ 10143 if(!showArch && docIsArchived(d)) return false; // closed deals are archived out of view 10144 if(typeF && d.doc_type !== typeF) return false; 10145 if(!q) return true; 10146 var contact = d.contact_id ? gc(d.contact_id) : null; 10147 var tx = d.transaction_id ? TX.find(function(t){ return t.id===d.transaction_id; }) : null; 10148 var hay = [ d.file_name||'', d.doc_type||'', contact?fn(contact):'', tx?(tx.address||''):'' ].join(' ').toLowerCase(); 10149 return hay.indexOf(q) !== -1; 10150 }); 10151 listEl.innerHTML = ''; 10152 if(!filtered.length){ 10153 var emptyMsg = DOCS.length ? 'No documents match your search.' : 'No documents stored yet.'; 10154 if(!showArch && archCount && !q && !typeF) emptyMsg = 'No active documents. ' + archCount + ' archived with closed deals - tick "Show closed deals" to see them.'; 10155 listEl.appendChild(mkDiv('font-size:18px;color:var(--text3);padding:20px;text-align:center;', emptyMsg)); 10156 return; 10157 } 10158 10159 // ---- Bulk selection bar ----
10160 Array.from(selectedDocs).forEach(function(id){ // forget selections that scrolled out of view 10161 if(!filtered.some(function(d){ return String(d.id)===String(id); })) selectedDocs.delete(id); 10162 }); 10163 var bar = document.createElement('div'); 10164 bar.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border);flex-wrap:wrap;'; 10165 var cnt = mkDiv('font-size:15px;color:var(--text2);flex:1;min-width:120px;', ''); 10166 var selAllB = mkBtn('btn btn-g','Select All','font-size:14px;padding:4px 10px;'); 10167 var clrSelB = mkBtn('btn btn-g','Clear','font-size:14px;padding:4px 10px;'); 10168 var delSelB = mkBtn('btn btn-d','Delete Selected','font-size:14px;padding:4px 10px;'); 10169 function refreshBulkUI(){ 10170 cnt.textContent = selectedDocs.size 10171 ? selectedDocs.size + ' selected' 10172 : filtered.length + ' document' + (filtered.length===1 ? '' : 's') + (!showArch && archCount ? ' (' + archCount + ' archived)' : ''); 10173 delSelB.disabled = !selectedDocs.size; 10174 delSelB.style.opacity = selectedDocs.size ? '1' : '0.5'; 10175 } 10176 selAllB.addEventListener('click', function(){ filtered.forEach(function(d){ selectedDocs.add(d.id); }); renderDocsPage(); }); 10177 clrSelB.addEventListener('click', function(){ selectedDocs.clear(); renderDocsPage(); }); 10178 delSelB.addEventListener('click', async function(){ 10179 var picked = filtered.filter(function(d){ return selectedDocs.has(d.id); }); 10180 if(!picked.length) return; 10181 if(!confirm('Delete ' + picked.length + ' document(s)? The files will be permanently removed.')) return; 10182 delSelB.disabled = true; 10183 var res = await bulkDeleteDocuments(picked, function(done, total){ delSelB.textContent = 'Deleting ' + done + '/' + total + '...'; }); 10184 selectedDocs.clear(); 10185 renderDocsPage(); 10186 if(res.fail) alert('Delete finished, but ' + res.fail + ' request(s) failed. Some documents may remain.'); 10187 }); 10188 bar.appendChild(cnt); bar.appendChild(selAllB); bar.appendChild(clrSelB); bar.appendChild(delSelB); 10189 listEl.appendChild(bar); 10190 refreshBulkUI(); 10191 10192 // ---- Group documents by deal (transaction / street address), then by contact ---- 10193 var groups = {}, groupOrder = []; 10194 filtered.forEach(function(d){ 10195 var tx = d.transaction_id ? TX.find(function(t){ return t.id===d.transaction_id; }) : null; 10196 var contact = d.contact_id ? gc(d.contact_id) : (tx && tx.contactId ? gc(tx.contactId) : null); 10197 var key, kind, label, sub, txId=null, cId=null, sortKey; 10198 if(tx){ 10199 key='tx_'+tx.id; kind='tx'; txId=tx.id; 10200 label=(String(tx.address||'').split(',')[0].trim()) || 'Transaction'; 10201 sub=contact ? fn(contact) : ''; 10202 sortKey='0_'+label.toLowerCase(); 10203 } else if(contact){ 10204 key='c_'+contact.id; kind='contact'; cId=contact.id; 10205 label=fn(contact); sub='No deal attached'; 10206 sortKey='1_'+label.toLowerCase(); 10207 } else { 10208 key='unfiled'; kind='unfiled'; label='Unfiled'; sub='Not linked to a deal or contact'; 10209 sortKey='2_zzzz'; 10210 } 10211 if(!groups[key]){ groups[key]={kind:kind,label:label,sub:sub,txId:txId,cId:cId,sortKey:sortKey,docs:[]}; groupOrder.push(key); } 10212 groups[key].docs.push(d); 10213 }); 10214 groupOrder.sort(function(a,b){ return groups[a].sortKey.localeCompare(groups[b].sortKey); }); 10215 10216 groupOrder.forEach(function(key){ 10217 var g = groups[key]; 10218 // Group header: deal street address (or contact), the client name, and a doc count. 10219 var hdr = document.createElement('div'); 10220 hdr.style.cssText = 'display:flex;align-items:center;gap:10px;padding:11px 8px 8px;margin-top:14px;border-bottom:2px solid var(--border);'+(g.kind==='unfiled'?'':'cursor:pointer;'); 10221 hdr.appendChild(mkDiv('font-size:16px;flex:0 0 auto;', g.kind==='tx'?'ð ':(g.kind==='contact'?'ð¤':'ð'))); 10222 var titleWrap = mkDiv('flex:1;min-width:0;'); 10223 titleWrap.appendChild(mkDiv('font-size:17px;font-weight:700;color:var(--text);word-break:break-word;', g.label)); 10224 if(g.sub) titleWrap.appendChild(mkDiv('font-size:14px;color:var(--text3);margin-top:1px;', g.sub)); 10225 hdr.appendChild(titleWrap); 10226 hdr.appendChild(mkDiv('flex:0 0 auto;font-size:13px;color:var(--text2);background:var(--surface2);border:1px solid var(--border);border-radius:20px;padding:2px 11px;', g.docs.length+' doc'+(g.docs.length===1?'':'s'))); 10227 if(g.kind==='tx'){ (function(id){ hdr.addEventListener('click', function(){ sp('tc'); openTCDetail(id); }); })(g.txId); } 10228 else if(g.kind==='contact'){ (function(id){ hdr.addEventListener('click', function(){ sp('contacts'); vc(id); }); })(g.cId); } 10229 listEl.appendChild(hdr); 10230 10231 // Documents under this deal. 10232 g.docs.slice().sort(function(a,b){ return (b.id||0)-(a.id||0); }
10232).forEach(function(d){ 10233 var row = document.createElement('div'); 10234 row.style.cssText = 'display:flex;align-items:center;gap:12px;padding:9px 12px 9px 30px;border-bottom:1px solid var(--border);flex-wrap:wrap;'; 10235 var cbx = document.createElement('input'); 10236 cbx.type = 'checkbox'; cbx.checked = selectedDocs.has(d.id); 10237 cbx.style.cssText = 'accent-color:var(--accent);flex:0 0 auto;cursor:pointer;'; 10238 (function(doc){ cbx.addEventListener('change', function(){ if(cbx.checked) selectedDocs.add(doc.id); else selectedDocs.delete(doc.id); refreshBulkUI(); }); })(d); 10239 row.appendChild(cbx); 10240 var nameWrap = mkDiv('flex:1;min-width:180px;'); 10241 var name = mkDiv('font-size:18px;color:var(--accent);cursor:pointer;text-decoration:underline;word-break:break-word;', d.file_name||'Document'); 10242 (function(doc){ name.addEventListener('click', function(){ openDocument(doc); }); })(d); 10243 nameWrap.appendChild(name); 10244 var subBits = []; 10245 if(d.doc_type) subBits.push(d.doc_type); 10246 if(d.size) subBits.push(Math.round(d.size/1024)+' KB'); 10247 if(d.created_at) subBits.push(fd(d.created_at)); 10248 nameWrap.appendChild(mkDiv('font-size:15px;color:var(--text3);margin-top:2px;', subBits.join(' - '))); 10249 row.appendChild(nameWrap); 10250 var del = mkBtn('btn btn-d','Delete','font-size:14px;padding:4px 10px;flex:0 0 auto;'); 10251 (function(doc){ del.addEventListener('click', function(){ 10252 if(!confirm('Delete "'+(doc.file_name||'this document')+'"? The file will be permanently removed.')) return; 10253 deleteDocument(doc).then(function(){ renderDocsPage(); }).catch(function(e){ alert('Delete failed: '+e.message); }); 10254 }); })(d); 10255 row.appendChild(del); 10256 listEl.appendChild(row); 10257 }); 10258 }); 10259} 10260ge('sortByLast').addEventListener('click',function(){ 10261 curSort='last'; 10262 ge('sortByLast').classList.add('active'); ge('sortByFirst').classList.remove('active'); 10263 rc(); 10264}); 10265ge('btnSelectAll').addEventListener('click',function(){ 10266 C.forEach(function(c){ selectedContacts.add(c.id); }); 10267 updateBulkBar(); rc(); 10268}); 10269ge('btnClearSel').addEventListener('click',function(){ 10270 selectedContacts.clear(); updateBulkBar(); rc(); 10271}); 10272ge('btnDeleteSelected').addEventListener('click', deleteSelectedContacts); 10273ge('btnMergeContacts').addEventListener('click', openMergeModal); 10274ge('btnMergeBack').addEventListener('click', function(){ 10275 var btn=ge('btnConfirmMerge'); 10276 var idA=parseInt(btn.getAttribute('data-a'))||btn.getAttribute('data-a'); 10277 var idB=parseInt(btn.getAttribute('data-b'))||btn.getAttribute('data-b'); 10278 var a=gc(idA), b=gc(idB); if(!a||!b) return; 10279 mergeStep=1; 10280 ge('mergeStepLabel').textContent='Step 1 of 2 - Choose which values to keep'; 10281 ge('btnConfirmMerge').textContent='Review Merged Contact â'; 10282 ge('btnMergeBack').style.display='none'; 10283 showMergeStep1(a,b); 10284}); 10285ge('btnConfirmMerge').addEventListener('click', confirmMerge); 10286ge('sortByFirst').addEventListener('click',function(){ 10287 curSort='first'; 10288 ge('sortByFirst').classList.add('active'); ge('sortByLast').classList.remove('active'); 10289 rc(); 10290}); 10291ge('btnSaveToken').addEventListener('click', connectMLSToken); 10292ge('btnClearToken').addEventListener('click', disconnectMLS); 10293ge('btnMLSSearch').addEventListener('click', searchMLS); 10294ge('mlsDetClose').addEventListener('click', function(){ ge('mlsDetOv').classList.remove('open'); }); 10295ge('mlsDetOv').addEventListener('click', function(e){ if(e.target === ge('mlsDetOv')) ge('mlsDetOv').classList.remove('open'); }); 10296ge('mlsViewGrid').addEventListener('click', function(){ mlsViewMode='grid'; ge('mlsViewGrid').style.background='var(--accent)'; ge('mlsViewGrid').style.color='var(--bg)'; ge('mlsViewList').style.background='var(--surface2)'; ge('mlsViewList').style.color='var(--text2)'; renderMLSResults(); }); 10297ge('mlsViewList').addEventListener('click', function(){ mlsViewMode='list'; ge('mlsViewList').style.background='var(--accent)'; ge('mlsViewList').style.color='var(--bg)'; ge('mlsViewGrid').style.background='var(--surface2)'; ge('mlsViewGrid').style.color='var(--text2)'; renderMLSResults(); }); 10298document.querySelectorAll('[data-mls-filter]').forEach(function(b){ 10299 b.addEventListener('click', function(){ 10300 mlsStatusFilter = b.getAttribute('data-mls-filter'); 10301 document.querySelectorAll('[data-mls-filter]').forEach(function(x){ x.classList.remove('active'); }); 10302 b.classList.add('active'); 10303 searchMLS(); 10304 }); 10305}); 10306ge('mlsTokenInput').addEventListener('keydown', function(e){ if(e.key === 'Enter') connectMLSToken(); }); 10307try{ var saved = localStorage.getItem('mls_token'); if(saved){ mlsToken=saved; mlsLiveMode=true; } }catch(e){} 10308 10309// -- SUPABASE CONNECT -------------------------------------------------- 10310function connectSupa(){ 10311 var url = ge('supaUrlInput').value.trim(); 10312 var key = ge('supaKeyInput').value.trim(); 10313 if(!url || !key){ alert('Please enter both your Supabase URL and anon key.'); return; } 10314 SUPA_URL = url; SUPA_KEY = key; 10315 try{ localStorage.setItem('supa_url', url); localStorage.setItem('supa_key', key); }catch(e){} 10316 if(initSupabase()){ 10317 updateSupaBanner(true); 10318 loadFromDB().then(function(ok){ 10319 if(ok){ rd(); subscribeRealtime(); }
10320 else{ alert('Connected but could not load data. Check your keys and make sure the database tables exist.'); } 10321 }); 10322 } else { 10323 alert('Could not connect. Check your URL and key.'); 10324 } 10325} 10326 10327function disconnectSupa(){ 10328 SUPA_URL=''; SUPA_KEY=''; supa=null; supaReady=false; 10329 try{ localStorage.removeItem('supa_url'); localStorage.removeItem('supa_key'); }catch(e){} 10330 updateSupaBanner(false); 10331 showSyncStatus('off'); 10332 ge('syncBar').style.display='none'; 10333} 10334 10335function updateSupaBanner(connected){ 10336 var banner = ge('setupBanner'); 10337 var connectBtn = ge('btnConnectSupa'); 10338 var disconnectBtn = ge('btnDisconnectSupa'); 10339 var urlInput = ge('supaUrlInput'); 10340 var keyInput = ge('supaKeyInput'); 10341 if(connected){ 10342 banner.style.background = 'rgba(107,201,122,0.08)'; 10343 banner.style.borderColor = 'var(--lead)'; 10344 banner.querySelector('div > div:first-child').textContent = 'Cloud Sync Active'; 10345 banner.querySelector('div > div:last-child').textContent = 'Your data is synced across all devices in real time'; 10346 connectBtn.style.display = 'none'; 10347 disconnectBtn.style.display = 'inline-flex'; 10348 urlInput.style.display = 'none'; 10349 keyInput.style.display = 'none'; 10350 } else { 10351 banner.style.background = 'var(--surface)'; 10352 banner.style.borderColor = 'var(--border)'; 10353 banner.querySelector('div > div:first-child').textContent = 'Connect Cloud Sync'; 10354 banner.querySelector('div > div:last-child').textContent = 'Paste your Supabase URL and key to sync across all devices in real time'; 10355 connectBtn.style.display = 'inline-flex'; 10356 disconnectBtn.style.display = 'none'; 10357 urlInput.style.display = 'inline-block'; 10358 keyInput.style.display = 'inline-block'; 10359 } 10360} 10361 10362ge('btnConnectSupa').addEventListener('click', connectSupa); 10363ge('btnDisconnectSupa').addEventListener('click', disconnectSupa); 10364ge('supaKeyInput').addEventListener('keydown', function(e){ if(e.key==='Enter') connectSupa(); }); 10365ge('syncDot') && ge('syncDot').addEventListener('click', function(){ 10366 if(supaReady){ if(confirm('Disconnect cloud sync? Your data stays on this device until you reconnect.')) disconnectSupa(); } 10367 else { connectSupa(); } 10368}); 10369showSyncStatus(supaReady ? 'live' : 'off'); 10370 10371// -- STARTUP ---------------------------------------------------------- 10372// -- AUTH SYSTEM -- 10373var currentUser = null; 10374// ---- Access control (UI-level) ---- 10375// Owners (below) get full access. ANYONE ELSE who signs in is automatically a LIMITED user: the 10376// Investments tabs (Properties + Hard Money) are hidden and all deletions are blocked. So a 10377// temporary helper just needs a Supabase login that isn't on this list - no code change needed. 10378// To grant a new permanent owner, add their login email (lowercase) here. 10379var OWNER_EMAILS = ['[email protected]','[email protected]','[email protected]']; 10380var IS_RESTRICTED = false; 10381var _noDelAlertT = 0; 10382function _notifyNoDelete(){ var n=Date.now(); if(n-_noDelAlertT>1500){ _noDelAlertT=n; try{ alert("You don't have permission to delete items."); }catch(e){} } } 10383function computeRestricted(){ var e=(currentUser&¤tUser.email)?currentUser.email.toLowerCase():''; IS_RESTRICTED = !!e && OWNER_EMAILS.indexOf(e)<0; return IS_RESTRICTED; } 10384function applyRestrictions(){ 10385 computeRestricted(); 10386 ['nav-investments','nav-hardmoney'].forEach(function(id){ var el=ge(id); if(el) el.style.display = IS_RESTRICTED ? 'none' : ''; }); 10387 // Hide the whole Investments section (group + its collapsible header) for limited users. 10388 var grp=document.querySelector('.nav-group[data-sec="Investments"]'); 10389 var hdr=document.querySelector('.nav-toggle[data-sec="Investments"]'); 10390 if(hdr) hdr.style.display = IS_RESTRICTED ? 'none' : ''; 10391 if(grp && IS_RESTRICTED) grp.style.display = 'none'; 10392 if(!grp){ var ni=ge('nav-investments'); if(ni && ni.previousElementSibling && ni.previousElementSibling.classList && ni.previousElementSibling.classList.contains('nav-sec')) ni.previousElementSibling.style.display = IS_RESTRICTED ? 'none' : ''; } 10393 if(IS_RESTRICTED && (curPage==='investments'||curPage==='hardmoney')){ try{ sp('dashboard'); }catch(e){} } 10394} 10395// Block every Supabase DELETE for a limited user at one choke point (covers all delete code paths). 10396(function(){ 10397 if(typeof window==='undefined' || !window.fetch) return; 10398 var _of = window.fetch; 10399 window.fetch = function(url, opts){ 10400 try{ 10401 if(IS_RESTRICTED && opts && String(opts.method||'').toUpperCase()==='DELETE' && String(url||'').indexOf('/rest/v1/')>=0){ 10402 _notifyNoDelete(); 10403 return Promise.resolve(new Response('{"error":"forbidden"}', { status:403, headers:{'Content-Type':'application/json'} })); 10404 } 10405 }catch(e){} 10406 return _of.apply(this, arguments); 10407 }; 10408})(); 10409// ---- Sidebar: 5 logical groups, each collapsible (state remembered per signed-in user) ---- 10410var NAV_GROUPS=[ 10411 ['Home', ['nav-briefing','nav-pipeline']], 10412 ['Clients', ['nav-contacts','nav-followups','nav-notes','nav-drips']], 10413 ['Deals', ['nav-tc','nav-listings','nav-dealsheet','nav-documents','nav-deadlines']], 10414 ['Tools', ['nav-gmail','nav-calendar','nav-scanner','nav-cardscanner','nav-newconstruction','nav-team']], 10415 ['Commercial', ['nav-commercial','nav-loi']], 10416 ['Investments', ['nav-investments','nav-hardmoney']] 10417]; 10418function _navSecKey(){ var e=(currentUser&¤tUser.email)?currentUser.email.toLowerCase():''; return 'navsec:'+e; } 10419function _navSecState(){ try{ return JSON.parse(localStorage.getItem(_navSecKey())||'{}'); }catch(e){ return {}; } } 10420function _navSecSave(sec,open){ var st=_navSecState(); st[sec]=open; try{ localStorage.setItem(_navSecKey(), JSON.stringify(st)); }catch(e){} } 10421function _navSecOpen(sec){ var st=_navSecState(); return (sec in st) ? !!st[sec] : true; } // default: open 10422function _setNavGroup(hdr,grp,open){ grp.style.display=open?'':'none'; var ch=hdr.querySelector('.nav-chev'); if(ch) ch.textContent=open?'â¾':'â¸'; } 10423function setupCollapsibleNav(){ 10424 var nav=document.querySelector('nav'); if(!nav || nav._grouped) return; 10425 if(!ge('navCollapseCss')){ var st=document.createElement('style'); st.id='navCollapseCss'; 10426 st.textContent='.nav-toggle{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;' 10427 +'background:var(--surface3);border:1px solid var(--border);border-radius:7px;padding:7px 10px;margin:10px 4px 5px;}' 10428 +'.nav-toggle:hover{background:var(--surface2);} .nav-chev{font-size:11px;opacity:.7;margin-right:2px;}'; 10429 document.head.appendChild(st); } 10430 var btns={};
10430 NAV_GROUPS.forEach(function(g){ g[1].forEach(function(id){ var b=ge(id); if(b) btns[id]=b; }); }); 10431 Array.prototype.slice.call(nav.querySelectorAll('.nav-sec')).forEach(function(h){ if(!h.classList.contains('nav-toggle')) h.remove(); }); 10432 NAV_GROUPS.forEach(function(g){ 10433 var have=g[1].filter(function(id){ return btns[id]; }); if(!have.length) return; 10434 var hdr=document.createElement('div'); hdr.className='nav-sec nav-toggle'; hdr.setAttribute('data-sec',g[0]); 10435 hdr.innerHTML='<span>'+g[0]+'</span><span class="nav-chev">â¾</span>'; nav.appendChild(hdr); 10436 var grp=document.createElement('div'); grp.className='nav-group'; grp.setAttribute('data-sec',g[0]); 10437 have.forEach(function(id){ grp.appendChild(btns[id]); }); nav.appendChild(grp); 10438 _setNavGroup(hdr,grp,_navSecOpen(g[0])); 10439 hdr.addEventListener('click',function(){ var open=(grp.style.display==='none'); _setNavGroup(hdr,grp,open); _navSecSave(g[0],open); }); 10440 }); 10441 nav._grouped=true; 10442} 10443// Keep the section holding the current page expanded so the active item is always visible. 10444function openActiveNavSection(){ 10445 var active=document.querySelector('nav .ni.active'); if(!active) return; 10446 var grp=active.parentNode; if(!grp || !grp.classList || !grp.classList.contains('nav-group')) return; 10447 if(grp.style.display==='none'){ var hdr=document.querySelector('.nav-toggle[data-sec="'+grp.getAttribute('data-sec')+'"]'); if(hdr) _setNavGroup(hdr,grp,true); } 10448} 10449var myMemberId = null; // team member id matching whoever is signed into the CRM right now 10450 10451function showLoginScreen(){ 10452 ge('loginScreen').classList.add('visible'); 10453 ge('mainApp').style.display = 'none'; 10454 clearLoginMessages(); 10455} 10456 10457// Which team member is signed in right now. TM lives only in localStorage and is loaded 10458// lazily by initTeam()/initGmail(), so this pulls it in itself rather than assuming. 10459function resolveMyMember(){ 10460 if(!currentUser || !currentUser.email) return null; 10461 if(!TM.length){ try{ var s = localStorage.getItem('crm_tm'); if(s) TM = JSON.parse(s); }catch(e){} } 10462 var m = TM.find(function(x){ return x.email && x.email.toLowerCase() === currentUser.email.toLowerCase(); }); 10463 myMemberId = m ? m.id : null; 10464 return myMemberId; 10465} 10466 10467function showApp(user){ 10468 currentUser = user; 10469 ge('loginScreen').classList.remove('visible'); 10470 ge('mainApp').style.display = 'flex'; 10471 // Set topbar initials 10472 var initials = user.email ? user.email.substring(0,2).toUpperCase() : 'PB'; 10473 // Try to match to team member 10474 resolveMyMember(); 10475 var tm = TM.find(function(m){ return m.email && m.email.toLowerCase() === user.email.toLowerCase(); }); 10476 if(tm) initials = (tm.first.charAt(0) + tm.last.charAt(0)).toUpperCase(); 10477 ge('topbarUser').textContent = initials; 10478 ge('topbarUser').title = user.email + ' (click to sign out)'; 10479 try{ setupCollapsibleNav(); }catch(e){} // regroup + collapsible sidebar (per-user state) 10480 applyRestrictions(); 10481 try{ openActiveNavSection(); }catch(e){} 10482} 10483 10484function clearLoginMessages(){ 10485 ge('loginErr').style.display = 'none'; 10486 ge('loginMsg').style.display = 'none'; 10487} 10488 10489function showLoginErr(msg){ 10490 ge('loginErr').textContent = msg; 10491 ge('loginErr').style.display = 'block'; 10492 ge('loginMsg').style.display = 'none'; 10493} 10494 10495function showLoginMsg(msg){ 10496 ge('loginMsg').textContent = msg; 10497 ge('loginMsg').style.display = 'block'; 10498 ge('loginErr').style.display = 'none'; 10499} 10500 10501async function signInWithGoogle(){ 10502 clearLoginMessages(); 10503 // Make sure Supabase is ready 10504 if(!supaReady || !supa){ 10505 initSupabase(); 10506 if(!supaReady || !supa){ 10507 showLoginErr('Connection not ready. Please refresh the page and try again.'); 10508 return; 10509 } 10510 } 10511 ge('btnGoogleLogin').textContent = 'Signing in...'; 10512 ge('btnGoogleLogin').disabled = true; 10513 try{ 10514 var redirectTo = window.location.origin + window.location.pathname; 10515 console.log('OAuth redirectTo:', redirectTo); 10516 var result = await supa.auth.signInWithOAuth({ 10517 provider: 'google', 10518 options: { 10519 redirectTo: redirectTo, 10520 queryParams: { access_type: 'offline', prompt: 'consent' } 10521 } 10522 }); 10523 if(result.error){ showLoginErr(result.error.message); } 10524 }catch(e){ showLoginErr('Google sign in failed: ' + e.message); } 10525 ge('btnGoogleLogin').innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg> Sign in with Google'; 10526 ge('btnGoogleLogin').disabled = false;
10527} 10528 10529async function signInWithEmail(){ 10530 clearLoginMessages(); 10531 if(!supaReady || !supa){ 10532 initSupabase(); 10533 if(!supaReady || !supa){ 10534 showLoginErr('Connection not ready. Please refresh the page and try again.'); 10535 return; 10536 } 10537 } 10538 var email = ge('loginEmail').value.trim(); 10539 var password = ge('loginPassword').value; 10540 if(!email || !password){ showLoginErr('Please enter your email and password.'); return; } 10541 var btn = ge('btnEmailLogin'); 10542 btn.textContent = 'Signing in...'; btn.disabled = true; 10543 try{ 10544 var result = await supa.auth.signInWithPassword({ email: email, password: password }); 10545 if(result.error){ showLoginErr(result.error.message); } 10546 else { onAuthSuccess(result.data.user); } 10547 }catch(e){ showLoginErr('Sign in failed: ' + e.message); } 10548 btn.textContent = 'Sign In'; btn.disabled = false; 10549} 10550 10551async function sendPasswordReset(){ 10552 clearLoginMessages(); 10553 var email = ge('resetEmail').value.trim(); 10554 if(!email){ showLoginErr('Please enter your email address.'); return; } 10555 var result = await supa.auth.resetPasswordForEmail(email, { 10556 redirectTo: window.location.origin + window.location.pathname 10557 }); 10558 if(result.error){ showLoginErr(result.error.message); } 10559 else { showLoginMsg('Reset link sent! Check your email.'); } 10560} 10561 10562async function signOut(){ 10563 if(!confirm('Sign out of Palacios Baker RE?')) return; 10564 await supa.auth.signOut(); 10565 currentUser = null; 10566 authInitDone = false; 10567 C=[]; N=[]; F=[]; D=[]; TX=[]; 10568 showLoginScreen(); 10569} 10570 10571var authInitDone = false; 10572function onAuthSuccess(user){ 10573 if(authInitDone) return; // prevent double-fire from SIGNED_IN + INITIAL_SESSION 10574 authInitDone = true; 10575 showApp(user); 10576 updateSupaBanner(supaReady); 10577 loadFromDB().then(function(ok){ 10578 if(!ok && !C.length) demo(); 10579 rd(); 10580 subscribeRealtime(); 10581 updateNbTC(); 10582 try{ updateInvBadge(); }catch(e){} // investments "needs attention" count on the sidebar 10583 if(ok) try{ sweepStaleItems(); }catch(e){} // auto-remove orphaned/fired items so they can't resurface 10584 if(ok) try{ migrateAppraisalSteps(); }catch(e){} // carry legacy appraisal-done marks onto the new single "Appraisal" step 10585 if(ok) try{ materializeWorkflowTasks(); }catch(e){} // generate dated tasks from workflow templates 10586 restoreTabFromHash(); 10587 // Email-schedule editor now lives on the dashboard; ensure it populates on first load 10588 // (restoreTabFromHash only triggers it when a #hash routes through sp()). 10589 try{ loadBriefingSchedule(); }catch(e){} 10590 try{ loadSendingControls(); }catch(e){} 10591 // Know which Gmail accounts are connected from the start, not just after visiting 10592 // the Gmail tab - enrolling and sending need this everywhere. 10593 try{ checkConnectedAccounts(); }catch(e){} 10594 }); 10595} 10596 10597// ---- Briefing Schedule ---- 10598var bsEntries = []; // in-memory list of schedule entries 10599 10600function bsRender(){ 10601 var el = ge('bsScheduleList'); 10602 if(!bsEntries.length){ 10603 el.innerHTML = '<div style="font-size:14px;color:var(--text3);padding:4px 0;">No schedules yet. Add one below.</div>'; 10604 return; 10605 } 10606 el.innerHTML = ''; 10607 bsEntries.forEach(function(e, i){ 10608 var row = document.createElement('div'); 10609 row.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;margin-bottom:8px;'; 10610 var info = document.createElement('div'); 10611 var label = e.label ? '<span style="font-size:15px;font-weight:600;color:var(--accent);margin-right:10px;">'+e.label+'</span>' : ''; 10612 var days = '<span style="font-size:14px;color:var(--text);">'+e.days.join(', ')+'</span>'; 10613 var time = '<span style="font-family:monospace;font-size:15px;color:var(--text2);margin-left:12px;">'+e.time+'</span>'; 10614 info.innerHTML = label + days + time; 10615 var del = document.createElement('button'); 10616 del.className = 'btn btn-d'; 10617 del.style.cssText = 'padding:3px 10px;font-size:13px;'; 10618 del.textContent = 'Remove'; 10619 (function(idx){ del.addEventListener('click', function(){ bsEntries.splice(idx,1); bsRender(); }); })(i); 10620 row.appendChild(info); 10621 row.appendChild(del); 10622 el.appendChild(row); 10623 }); 10624} 10625 10626async function loadBriefingSchedule(){ 10627 if(!supaReady) return; 10628 try{ 10629 var headers = await getAuthHeaders();
10630 var r = await fetch(SUPA_URL+'/rest/v1/settings?key=eq.briefing_schedule&select=value',{ 10631 headers: headers 10632 }); 10633 var text = await r.text(); 10634 if(!text) return; 10635 var rows = JSON.parse(text); 10636 if(rows.length){ 10637 var s = rows[0].value; 10638 if(s.timezone) ge('bsTZ').value = s.timezone; 10639 bsEntries = s.entries || []; 10640 var lastSent = s.lastSentByTime || {}; 10641 var keys = Object.keys(lastSent); 10642 if(keys.length){ 10643 var latest = keys.reduce(function(a,b){ return lastSent[a]>lastSent[b]?a:b; }); 10644 ge('briefingScheduleStatus').textContent = 'Last sent: '+lastSent[latest]+' at '+latest; 10645 } 10646 bsRender(); 10647 } 10648 }catch(e){} 10649} 10650 10651ge('btnAddSchedule').addEventListener('click', function(){ 10652 var days = []; 10653 ge('bsDays').querySelectorAll('input[type=checkbox]').forEach(function(cb){ if(cb.checked) days.push(cb.value); }); 10654 if(!days.length){ alert('Select at least one day.'); return; } 10655 var time = ge('bsTime').value; 10656 if(!time){ alert('Pick a time.'); return; } 10657 var label = ge('bsLabel').value.trim(); 10658 bsEntries.push({days:days, time:time, label:label}); 10659 ge('bsDays').querySelectorAll('input[type=checkbox]').forEach(function(cb){ cb.checked=false; }); 10660 ge('bsLabel').value = ''; 10661 bsRender(); 10662}); 10663 10664async function saveBriefingSchedule(){ 10665 if(!supaReady){ alert('Connect to Supabase first.'); return; } 10666 var payload = { 10667 key: 'briefing_schedule', 10668 value: { timezone: ge('bsTZ').value, entries: bsEntries, lastSentByTime: {} } 10669 }; 10670 try{ 10671 var headers = await getAuthHeaders({'Prefer':'resolution=merge-duplicates'}); 10672 var r = await fetch(SUPA_URL+'/rest/v1/settings',{ 10673 method: 'POST', 10674 headers: headers, 10675 body: JSON.stringify(payload) 10676 }); 10677 if(r.ok){ 10678 ge('briefingScheduleStatus').textContent = 'Saved!'; 10679 setTimeout(function(){ ge('briefingScheduleStatus').textContent=''; }, 3000); 10680 } else { 10681 var errText = await r.text(); 10682 alert('Save failed: '+errText); 10683 } 10684 }catch(e){ alert('Save failed: '+e.message); } 10685} 10686ge('btnSaveBriefingSchedule').addEventListener('click', saveBriefingSchedule); 10687 10688// ---- Sending Controls: master + per-type switches + test mode, stored in the settings table and 10689// read by the cron senders (run-drips, cron-briefing). Everything defaults OFF. ---- 10690async function loadSendingControls(){ 10691 if(!supaReady) return; 10692 try{ 10693 var headers = await getAuthHeaders(); 10694 var r = await fetch(SUPA_URL+'/rest/v1/settings?key=eq.sending_controls&select=value',{ headers: headers }); 10695 var text = await r.text(); if(!text) return; 10696 var rows = JSON.parse(text); if(!rows.length) return; 10697 var v = rows[0].value||{}; 10698 if(ge('scMaster')) ge('scMaster').checked = !!v.master; 10699 if(ge('scReminders')) ge('scReminders').checked = !!v.reminders; 10700 if(ge('scDrips')) ge('scDrips').checked = !!v.drips; 10701 if(ge('scBriefing')) ge('scBriefing').checked = !!v.briefing; 10702 if(ge('scTestMode')) ge('scTestMode').checked = (v.testMode!==false); 10703 if(ge('scTestEmail')) ge('scTestEmail').value = v.testEmail||''; 10704 }catch(e){} 10705} 10706async function saveSendingControls(){ 10707 if(!supaReady){ alert('Connect to Supabase first.'); return; } 10708 var val = { 10709 master: ge('scMaster').checked, reminders: ge('scReminders').checked, drips: ge('scDrips').checked, 10710 briefing: ge('scBriefing').checked, testMode: ge('scTestMode').checked, testEmail: (ge('scTestEmail').value||'').trim() 10711 }; 10712 if(val.master && !val.testMode){ if(!confirm('Master sending is ON and Test mode is OFF â real clients will receive automated emails. Save and go live?')) return; } 10713 if(val.master && val.testMode && !val.testEmail){ alert('Enter a test address (or turn off Test mode).'); return; } 10714 try{ 10715 var headers = await getAuthHeaders({'Prefer':'resolution=merge-duplicates'}); 10716 var r = await fetch(SUPA_URL+'/rest/v1/settings',{ method:'POST', headers: headers, body: JSON.stringify({ key:'sending_controls', value: val }) }); 10717 if(r.ok){ ge('sendingControlsStatus').textContent='Saved!'; setTimeout(function(){ ge('sendingControlsStatus').textContent=''; },3000); } 10718 else { alert('Save failed: '+(await r.text())); } 10719 }catch(e){ alert('Save failed: '+e.message); } 10720} 10721(function(){ var b=ge('btnSaveSendingControls'); if(b) b.addEventListener('click', saveSendingControls); })(); 10722 10723async function startup(){ 10724 try{ 10725 // Show login screen immediately 10726 ge('loginScreen').classList.add('visible'); 10727 10728 // Wait for Supabase CDN 10729 var waited = 0; 10730 while(!window.supabase && waited < 50){ 10731 await new Promise(function(r){ setTimeout(r, 100); }); 10732 waited++; 10733 } 10734 10735 if(!window.supabase){
10736 showLoginErr('Could not load authentication library. Please refresh.'); 10737 return; 10738 } 10739 10740 initSupabase(); 10741 10742 if(!supaReady){ 10743 // No Supabase config - load from localStorage 10744 try{ 10745 var lc=localStorage.getItem('crm_c'); 10746 if(lc){ C=JSON.parse(lc); N=JSON.parse(localStorage.getItem('crm_n')||'[]'); F=JSON.parse(localStorage.getItem('crm_f')||'[]'); D=JSON.parse(localStorage.getItem('crm_d')||'[]'); TX=JSON.parse(localStorage.getItem('crm_tx')||'[]'); A=JSON.parse(localStorage.getItem('crm_a')||'[]'); } 10747 else demo(); 10748 }catch(e2){ demo(); } 10749 ge('mainApp').style.display = 'flex'; 10750 ge('loginScreen').classList.remove('visible'); 10751 rd(); return; 10752 } 10753 10754 // Register auth listener 10755 supa.auth.onAuthStateChange(function(event, session){ 10756 console.log('Auth event:', event, session && session.user ? session.user.email : 'no user'); 10757 if(event === 'SIGNED_IN' && session && session.user && !currentUser){ 10758 onAuthSuccess(session.user); 10759 } else if(event === 'INITIAL_SESSION'){ 10760 if(session && session.user){ 10761 onAuthSuccess(session.user); 10762 } else { 10763 // No session - show login 10764 ge('loginScreen').classList.add('visible'); 10765 } 10766 } else if(event === 'SIGNED_OUT'){ 10767 currentUser = null; 10768 C=[]; N=[]; F=[]; D=[]; TX=[]; 10769 ge('mainApp').style.display = 'none'; 10770 ge('loginScreen').classList.add('visible'); 10771 } 10772 }); 10773 10774 }catch(e){ 10775 console.log('Startup error:', e.message, e.stack); 10776 ge('loginScreen').classList.add('visible'); 10777 showLoginErr('Startup error: ' + e.message + '. Please refresh.'); 10778 } 10779} 10780ge('nav-dashboard').addEventListener('click',function(){sp('dashboard');}); 10781ge('nav-pipeline').addEventListener('click',function(){sp('pipeline');}); 10782ge('nav-contacts').addEventListener('click',function(){sp('contacts');}); 10783ge('nav-followups').addEventListener('click',function(){sp('followups');}); 10784ge('nav-notes').addEventListener('click',function(){sp('notes');}); 10785ge('nav-deadlines').addEventListener('click',function(){sp('deadlines');}); 10786// --- Dashboard stat tiles: clickable drill-downs --- 10787function dashToContacts(f){ 10788 curFilter=f; 10789 document.querySelectorAll('.filter-btn[data-filter]').forEach(function(b){ 10790 b.classList.toggle('active', b.getAttribute('data-filter')===f); 10791 }); 10792 sp('contacts'); 10793} 10794[['sBuyers','buyer'],['sSellers','seller'],['sLeads','prospect']].forEach(function(pair){ 10795 var el=ge(pair[0]); if(!el||!el.parentNode) return; 10796 var tile=el.parentNode; tile.style.cursor='pointer'; 10797 (function(f){ tile.addEventListener('click',function(){dashToContacts(f);}); })(pair[1]); 10798}); 10799(function(){ 10800 var cl=ge('sClosing'); if(!cl||!cl.parentNode) return; 10801 cl.parentNode.style.cursor='pointer'; 10802 cl.parentNode.addEventListener('click',function(){sp('tc');}); 10803})(); 10804ge('hamburger').addEventListener('click',tsb); 10805ge('sbOv').addEventListener('click',csb); 10806ge('btnAdd').addEventListener('click',function(){om('addModal');}); 10807ge('btnPipelineAdd').addEventListener('click',function(){om('addModal');}); 10808ge('btnContactAdd').addEventListener('click',function(){ 10809 ge('btnSaveContact') && ge('btnSaveContact').setAttribute('data-edit-id',''); 10810 ge('addModalTitle') && (ge('addModalTitle').textContent='Add Contact'); 10811 ['fFirst','fLast','fWhatsapp','fPrice','fNotes','fCloseDate'].forEach(function(x){ge
10811(x).value='';}); 10812 clearFTypeValues(); ge('fStage').value=''; 10813 mvRender('fPhones',[]); mvRender('fEmails',[]); mvRender('fAddresses',[]); 10814 om('addModal'); 10815}); 10816ge('fPhonesAdd') && ge('fPhonesAdd').addEventListener('click',function(){mvAddRow('fPhones','','');}); 10817ge('fEmailsAdd') && ge('fEmailsAdd').addEventListener('click',function(){mvAddRow('fEmails','','');}); 10818ge('fAddressesAdd') && ge('fAddressesAdd').addEventListener('click',function(){mvAddRow('fAddresses','','');}); 10819ge('btnAddFU').addEventListener('click',function(){ge('btnSaveFU')&&ge('btnSaveFU').setAttribute('data-edit-id','');ge('fuLabel').value='';fs('fuContact');fsDeals('fuDeal',ge('fuContact').value,'');ge('fuDate').value=tod();om('fuModal');}); 10820ge('btnAddNote').addEventListener('click',function(){ge('btnSaveNote')&&ge('btnSaveNote').setAttribute('data-edit-id','');ge('nText').value='';if(ge('nCategory'))ge('nCategory').value='General';var _np=ensureNotePicker();if(_np)_np.setContact(null);fsDeals('nDeal','','');om('noteModal');}); 10821// Notes tab: search, category chips, collapse-all 10822(function(){ 10823 var s=ge('nSearch'); if(s) s.addEventListener('input',function(){ _noteSearch=this.value; rn(); }); 10824 var cf=ge('nCatFilter'); 10825 if(cf) cf.addEventListener('click',function(e){ 10826 var btn=e.target.closest('.nchip'); if(!btn) return; 10827 _noteCat=btn.getAttribute('data-cat'); 10828 cf.querySelectorAll('.nchip').forEach(function(x){ x.classList.toggle('nchip-on', x===btn); }); 10829 rn(); 10830 }); 10831 var tg=ge('nToggleGroups'); 10832 if(tg) tg.addEventListener('click',function(){ 10833 _noteAllCollapsed=!_noteAllCollapsed; 10834 // Apply to every currently visible group 10835 var keys={}; 10836 N.forEach(function(n){ keys[n.contactId?String(n.contactId):'unknown']=1; }); 10837 Object.keys(keys).forEach(function(k){ _noteCollapsed[k]=_noteAllCollapsed; }); 10838 this.textContent=_noteAllCollapsed?'Expand all':'Collapse all'; 10839 rn(); 10840 }); 10841 var sa=ge('nShowArchived'); 10842 if(sa) sa.addEventListener('change',function(){ _showArchivedNotes=this.checked; rn(); }); 10843})(); 10844ge('btnAddDL').addEventListener('click',function(){ge('btnSaveDL')&&ge('btnSaveDL').setAttribute('data-edit-id','');fs('dlContact');dlContactAllowNone();ge('dlContact').value='';populateDeadlineTypes('');fsDeals('dlDeal',ge('dlContact').value,'');ge('dlDate').value=tod();if(ge('dlTime'))ge('dlTime').value='';populateAssignDropdowns();om('dlModal');}); 10845ge('btnViewNotes').addEventListener('click',function(){sp('notes');}); 10846ge('btnViewFU').addEventListener('click',function(){sp('followups');}); 10847ge('btnSaveContact').addEventListener('click',svc); 10848ge('btnSaveNote').addEventListener('click',svn); 10849(function(){ var c=ge('nContact'); if(c) c.addEventListener('change',function(){ fsDeals('nDeal', c.value, ''); }); })(); 10850ge('btnSaveFU').addEventListener('click',svfu); 10851(function(){ var c=ge('fuContact'); if(c) c.addEventListener('change',function(){ fsDeals('fuDeal', c.value, ''); }); })(); 10852ge('btnSaveDL').addEventListener('click',svdl); 10853(function(){ var c=ge('dlContact'); if(c) c.addEventListener('change',function(){ fsDeals('dlDeal', c.value, ''); }); })(); 10854ge('btnSaveDetNote').addEventListener('click',adn); 10855ge('detOv').addEventListener('click',function(e){if(e.target===ge('detOv'))cd();}); 10856// ---- Global search: matches across the whole CRM (contacts, transactions, documents, notes, 10857// follow-ups, deadlines). On the Contacts tab the box filters the contact list as before. ---- 10858function globalSearch(q){ 10859 q = String(q||'').trim().toLowerCase(); 10860 var res = { contacts:[], transactions:[], documents:[], notes:[], followups:[], deadlines:[] }; 10861 if(!q) return res; 10862 function hit(){ for(var i=0;i<arguments.length;i++){ if(String(arguments[i]==null?'':arguments[i]).toLowerCase().indexOf(q) >= 0) return true; } return false; } 10863 C.forEach(function(c){ if(hit(fn(c), c.email, c.phone, c.property, c.notes)) res.contacts.push(c); }); 10864 TX.forEach(function(t){ var c=gc(t.contactId); if(hit(t.address, t.mlsNum, t.notes, t.price, c?fn(c):'')) res.transactions.push(t); }); 10865 DOCS.forEach(function(d){ if(hit(d.file_name, d.doc_type, d.summary)) res.documents.push(d); });
10866 N.forEach(function(n){ if(hit(n.text, n.textEs)) res.notes.push(n); }); 10867 F.forEach(function(f){ if(hit(f.label)) res.followups.push(f); }); 10868 D.forEach(function(d){ if(hit(d.type)) res.deadlines.push(d); }); 10869 return res; 10870} 10871function hideGlobalSearch(){ var el=ge('globalSearchResults'); if(el){ el.style.display='none'; el.innerHTML=''; } } 10872function renderGlobalSearch(q){ 10873 var el = ge('globalSearchResults'); if(!el) return; 10874 q = String(q||'').trim(); 10875 if(!q){ hideGlobalSearch(); return; } 10876 var r = globalSearch(q); 10877 el.innerHTML = ''; 10878 var total = r.contacts.length + r.transactions.length + r.documents.length + r.notes.length + r.followups.length + r.deadlines.length; 10879 if(!total){ el.appendChild(mkDiv('padding:14px;color:var(--text3);font-size:15px;', 'No matches for "'+q+'".')); el.style.display='block'; return; } 10880 function go(fn){ hideGlobalSearch(); ge('searchBox').value=''; fn(); } 10881 function group(title, items, rowText, onClick){ 10882 if(!items.length) return; 10883 el.appendChild(mkDiv('font-size:12px;text-transform:uppercase;letter-spacing:1px;color:var(--text3);padding:9px 9px 3px;font-weight:700;', title+' ('+items.length+')')); 10884 items.slice(0,6).forEach(function(it){ 10885 var row=document.createElement('div'); 10886 row.style.cssText='padding:7px 9px;border-radius:7px;cursor:pointer;font-size:15px;color:var(--text);word-break:break-word;'; 10887 row.addEventListener('mouseenter',function(){ this.style.background='var(--surface2)'; }); 10888 row.addEventListener('mouseleave',function(){ this.style.background=''; }); 10889 row.textContent = rowText(it); 10890 row.addEventListener('click', function(){ go(function(){ onClick(it); }); }); 10891 el.appendChild(row); 10892 }); 10893 if(items.length>6) el.appendChild(mkDiv('font-size:12px;color:var(--text3);padding:1px 9px 8px;', '+ '+(items.length-6)+' more')); 10894 } 10895 var deal = function(txId, contactId, fallback){ if(txId){ sp('tc'); openTCDetail(txId); } else if(contactId){ sp('contacts'); vc(contactId); } else sp(fallback); }; 10896 group('Contacts', r.contacts, function(c){ return fn(c) + (c.email?(' · '+c.email):(c.phone?(' · '+c.phone):'')); }, function(c){ sp('contacts'); vc(c.id); }); 10897 group('Transactions', r.transactions, function(t){ var c=gc(t.contactId); return (t.address||'Transaction') + (c?(' · '+fn(c)):''); }, function(t){ sp('tc'); openTCDetail(t.id); }); 10898 group('Documents', r.documents, function(d){ return (d.file_name||'Document') + (d.doc_type?(' · '+d.doc_type):''); }, function(d){ deal(d.transaction_id, d.contact_id, 'documents'); }); 10899 group('Notes', r.notes, function(n){ var c=gc(n.contactId); return String(n.text||'').slice(0,64) + (c?(' · '+fn(c)):''); }, function(n){ deal(n.transactionId, n.contactId, 'notes'); }); 10900 group('Follow-ups', r.followups, function(f){ var c=gc(f.contactId); return (f.label||'Follow-up') + (c?(' · '+fn(c)):''); }, function(f){ deal(f.transactionId, f.contactId, 'followups'); }); 10901 group('Deadlines', r.deadlines, function(d){ var c=gc(d.contactId); return (d.type||'Deadline') + (c?(' · '+fn(c)):''); }, function(d){ deal(d.transactionId, d.contactId, 'deadlines'); }); 10902 el.style.display='block'; 10903} 10904ge('searchBox').addEventListener('input',function(){ 10905 if(curPage==='gmail'){ hideGlobalSearch(); return; } 10906 if(curPage==='contacts'){ hideGlobalSearch(); rc(); return; } // Contacts tab: filter the contact list 10907 renderGlobalSearch(this.value); // Everywhere else: global results dropdown 10908}); 10909ge('searchBox').addEventListener('keydown',function(e){ 10910 if(e.key==='Escape'){ hideGlobalSearch(); return; } 10911 if(e.key!=='Enter') return; 10912 if(curPage==='gmail'){ 10913 // Mirror into the Gmail search box so the two never disagree about what's being searched. 10914 if(ge('gmailSearch')) ge('gmailSearch').value = ge('searchBox').value; 10915 loadGmailInbox(ge('searchBox').value); 10916 } 10917 else if(curPage==='contacts'){ rc(); } 10918 else { renderGlobalSearch(ge('searchBox').value); } 10919}); 10920// Close the global-search dropdown when clicking outside it or the search box. 10921document.addEventListener('click', function(e){ 10922 var el=ge('globalSearchResults'); if(!el || el.style.display==='none') return; 10923 if(e.target===ge('searchBox') || (el.contains && el.contains(e.target))) return; 10924 hideGlobalSearch(); 10925}); 10926 10927// ---- Gmail search bar ---- 10928if(ge('gmailSearchBtn')) ge('gmailSearchBtn').addEventListener('click', function(){ loadGmailInbox(); }); 10929if(ge('gmailSearch')) ge('gmailSearch').addEventListener('keydown', function(e){ if(e.key==='Enter') loadGmailInbox(); }); 10930if(ge('gmailSearchClear')) ge('gmailSearchClear').addEventListener('click', function(){ 10931 if(ge('gmailSearch')) ge('gmailSearch').value=''; 10932 if(ge('searchBox') && curPage==='gmail') ge('searchBox').value=''; 10933 loadGmailInbox(''); 10934}); 10935 10936// Auth event listeners 10937ge('btnGoogleLogin').addEventListener('click', signInWithGoogle); 10938ge('btnEmailLogin').addEventListener('click', signInWithEmail); 10939ge('loginPassword').addEventListener('keydown', function(e){ if(e.key==='Enter') signInWithEmail(); }); 10940ge('loginEmail').addEventListener('keydown', function(e){ if(e.key==='Enter') ge('loginPassword').focus(); }); 10941ge('btnForgotPassword').addEventListener('click', function(){ 10942 ge('loginEmailForm').style.display = 'none'; 10943 ge('loginResetForm').style.display = 'block'; 10944 ge('resetEmail').value = ge('loginEmail').value; 10945 clearLoginMessages(); 10946}); 10947ge('btnBackToLogin').addEventListener('click', function(){ 10948 ge('loginResetForm').style.display = 'none'; 10949 ge('loginEmailForm').style.display = 'block'; 10950 clearLoginMessages(); 10951}); 10952ge('btnSendReset').addEventListener('click', sendPasswordReset); 10953ge('topbarUser').addEventListener('click', signOut); 10954 10955// Card Scanner listeners 10956ge('btnCardUpload').addEventListener('click', function(){ ge('cardFileInput').click(); }); 10957ge('btnCardCamera').addEventListener('click', function(){ ge('cardCameraInput').click(); });
10958ge('cardFileInput').addEventListener('change', function(){ if(this.files[0]) startCardScan(this.files[0]); this.value=''; }); 10959ge('cardCameraInput').addEventListener('change', function(){ if(this.files[0]) startCardScan(this.files[0]); this.value=''; }); 10960ge('cardDrop').addEventListener('dragover', function(e){ e.preventDefault(); this.classList.add('dragover'); }); 10961ge('cardDrop').addEventListener('dragleave', function(){ this.classList.remove('dragover'); }); 10962ge('cardDrop').addEventListener('drop', function(e){ e.preventDefault(); this.classList.remove('dragover'); if(e.dataTransfer.files[0]) startCardScan(e.dataTransfer.files[0]); }); 10963ge('cardDrop').addEventListener('click', function(e){ if(e.target === this || e.target.classList.contains('scanner-drop-title') || e.target.classList.contains('scanner-drop-sub')) ge('cardFileInput').click(); }); 10964 10965// QR Scanner listeners 10966ge('tabCardPhoto').addEventListener('click', function(){ 10967 this.classList.add('active'); ge('tabQRCode').classList.remove('active'); 10968 ge('cardPhotoTab').style.display='block'; ge('qrCodeTab').style.display='none'; 10969 stopQRScanner(); 10970}); 10971ge('tabQRCode').addEventListener('click', function(){ 10972 this.classList.add('active'); ge('tabCardPhoto').classList.remove('active'); 10973 ge('cardPhotoTab').style.display='none'; ge('qrCodeTab').style.display='block'; 10974}); 10975ge('btnStartQR').addEventListener('click', startQRScanner); 10976ge('btnQRManualToggle').addEventListener('click', function(){ 10977 var mi = ge('qrManualInput'); 10978 mi.style.display = mi.style.display==='none' ? 'block' : 'none'; 10979}); 10980ge('btnParseQRText').addEventListener('click', function(){ 10981 var text = ge('qrManualText').value.trim(); 10982 if(!text){ alert('Please enter some text or a URL to parse.'); return; } 10983 parseQRData(text); 10984}); 10985ge('btnAddCalEvent').addEventListener('click', openAddCalEvent); 10986ge('btnSaveCalEvent').addEventListener('click', saveCalEvent); 10987ge('btnDeleteCalEvent').addEventListener('click', deleteCalEvent); 10988ge('calDetClose').addEventListener('click', function(){ ge('calDetOv').classList.remove('open'); }); 10989ge('calDetOv').addEventListener('click', function(e){ if(e.target===ge('calDetOv')) ge('calDetOv').classList.remove('open'); }); 10990ge('calPrev').addEventListener('click', function(){ 10991 if(CAL.view==='month'){ CAL.month--; if(CAL.month<0){CAL.month=11;CAL.year--;} } 10992 else if(CAL.view==='week'){ var d=new Date(CAL.selectedDate||tod()); d.setDate(d.getDate()-7); CAL.selectedDate=d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); } 10993 renderCalendar(); 10994}); 10995ge('calNext').addEventListener('click', function(){ 10996 if(CAL.view==='month'){ CAL.month++; if(CAL.month>11){CAL.month=0;CAL.year++;} } 10997 else if(CAL.view==='week'){ var d=new Date(CAL.selectedDate||tod()); d.setDate(d.getDate()+7); CAL.selectedDate=d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); } 10998 renderCalendar(); 10999}); 11000ge('calToday').addEventListener('click', function(){ 11001 CAL.month=new Date().getMonth(); CAL.year=new Date().getFullYear(); 11002 CAL.selectedDate=tod(); renderCalendar(); 11003}); 11004ge('btnRefreshGCal').addEventListener('click', function(){ 11005 CAL.gcalLastFetch = 0; // Force refetch 11006 this.textContent = 'Syncing...'; 11007 var btn = this; 11008 fetchGCalEvents().then(function(){ 11009 btn.textContent = 'Sync GCal'; 11010 }); 11011}); 11012document.querySelectorAll('[data-cal-view]').forEach(function(btn){ 11013 btn.addEventListener('click', function(){ 11014 CAL.view = this.getAttribute('data-cal-view'); 11015 document.querySelectorAll('[data-cal-view]').forEach(function(b){ b.classList.remove('active'); }); 11016 this.classList.add('active'); 11017 renderCalendar(); 11018 }); 11019}); 11020ge('btnGmailCompose').addEventListener('click', function(){ openCompose(); }); 11021ge('btnGmailRefresh').addEventListener('click', loadGmailInbox); 11022 11023ge('btnSendEmail').addEventListener('click', sendComposedEmail); 11024document.querySelectorAll('.gmail-template-btn').forEach(function(btn){ 11025 btn.addEventListener('click', function(){ applyTemplate(this.getAttribute('data-tpl')); }); 11026}); 11027ge('btnAddMember').addEventListener('click', openAddMember); 11028ge('btnSaveMember').addEventListener('click', saveMember); 11029ge('btnTeamSettings').addEventListener('click', function(){ sp('team'); }); 11030// Color swatches 11031document.querySelectorAll('.color-swatch').forEach(function(swatch){ 11032 swatch.addEventListener('click', function(){ 11033 selectedMemberColor = this.getAttribute('data-color'); 11034 document.querySelectorAll('.color-swatch').forEach(function(s){ s.style.border='2px solid transparent'; }); 11035 this.style.border = '2px solid white'; 11036 }); 11037}); 11038// Load TM from localStorage on startup 11039try{ 11040 var savedTM = localStorage.getItem('crm_tm'); 11041 if(savedTM && !TM.length) TM = JSON.parse(savedTM); 11042}catch(e){} 11043 11044// Scanner event listeners 11045ge('scannerDrop').addEventListener('click', function(){ ge('scannerFileInput').click(); }); 11046ge('scannerBrowse') && ge('scannerBrowse').addEventListener('click', function(e){ e.stopPropagation(); ge('scannerFileInput').click(); }); 11047ge('scannerFileInput').addEventListener('change', function(){ 11048 if(this.files[0]) startScan(this.files[0]); 11049}); 11050ge('scannerDrop').addEventListener('dragover', function(e){ 11051 e.preventDefault(); this.classList.add('dragover'); 11052}); 11053ge('scannerDrop').addEventListener('dragleave', function(){ 11054 this.classList.remove('dragover'); 11055}); 11056ge('scannerDrop').addEventListener('drop', function(e){ 11057 e.preventDefault(); this.classList.remove('dragover'); 11058 if(e.dataTransfer.files[0]) startScan(e.dataTransfer.files[0]); 11059}); 11060document.querySelectorAll('[data-doctype]').forEach(function(b){ 11061 b.addEventListener('click', function(){ 11062 scannerDocType = b.getAttribute('data-doctype'); 11063 document.querySelectorAll('[data-doctype]').forEach(function(x){ x.classList.remove('active'); }); 11064 b.classList.add('active'); 11065 }); 11066}); 11067// Load scanner history from localStorage 11068try{ 11069 var sh = localStorage.getItem('scanner_history'); 11070 if(sh) scannerHistory = JSON.parse(sh); 11071}catch(e){} 11072ge('btnNewTransaction').addEventListener('click', openNewTxModal); 11073ge('btnSaveTransaction').addEventListener('click', saveTransaction); 11074ge('btnTCAsk').addEventListener('click', askTCQuestion); 11075ge('tcAiInput').addEventListener('keydown', function(e){ if(e.key==='Enter') askTCQuestion(); }); 11076ge('tcDetClose').addEventListener('click', function(){ ge('tcDetOv').classList.remove('open'); curTx=null; }); 11077ge('tcDetOv').addEventListener('click', function(e){ if(e.target===ge('tcDetOv')){ ge('tcDetOv').classList.remove('open'); curTx=null; }}); 11078ge('tcDetEdit').addEventListener('click', function(){ 11079 if(!curTx) return; 11080 var tx = TX.find(function(t){ return t.id===curTx; }); 11081 if(!tx) return; 11082 ge('tcModalTitle').textContent = 'Edit Transaction'; 11083 ge('btnSaveTransaction').setAttribute('data-edit-id', tx.id); 11084 ge('tcType').value = tx.type||'buyer'; 11085 var txCPe = ge('tcContactPick'); txCPe.innerHTML = ''; 11086 var txPick = buildContactPicker(txCPe, 'tcContact', 'Search contact by name, email, phone...'); 11087 if(tx.contactId){ var txC = gc(tx.contactId); if(txC) txPick.setContact(txC); } 11088 ['tcAddress','tcPrice','tcMLS','tcLender','tcTitle','tcNotes','tcListCommPct','tcBuyerCommPct','tcListCommAmt','tcBuyerCommAmt'].forEach(function(x){ if(ge(x)) ge(x).value=''; }); 11089 ge('tcPrice').value = tx.price||''; 11090 ge('tcMLS').value = tx.mlsNum||''; 11091 ge('tcLender').value = tx.lender||''; 11092 ge('tcTitle').value = tx.titleCo||''; 11093 ge('tcNotes').value = tx.notes||''; 11094 if(ge('tcListCommPct')) ge('tcListCommPct').value = tx.listCommissionPct!=null ? tx.listCommissionPct : ''; 11095 if(ge('tcBuyerCommPct')) ge('tcBuyerCommPct').value = tx.buyerCommissionPct!=null ? tx.buyerCommissionPct : ''; 11096 if(ge('tcListCommAmt')) ge('tcListCommAmt').value = tx.listCommissionAmt!=null ? tx.listCommissionAmt : ''; 11097 if(ge('tcBuyerCommAmt')) ge('tcBuyerCommAmt').value = tx.buyerCommissionAmt!=null ? tx.buyerCommissionAmt : ''; 11098 if(ge('tcFinancingType')) ge('tcFinancingType').value = tx.financingType||''; 11099 ge('tcContractDate').value = tx.contractDate||''; 11100 ge('tcClosingDate').value = tx.closingDate||''; 11101 ge('tcEarnestDate').value = tx.earnestDate||''; 11102 ge('tcDueDiligDate').value = tx.dueDiligDate||''; 11103 ge('tcFinancingDate').value = tx.financingDate||''; 11104 ge('tcAppraisalDate').value = tx.appraisalDate||''; 11105 ge('tcModal').classList.add('open'); 11106}); 11107ge('tcDetDelete').addEventListener('click', function(){ 11108 if(curTx) deleteTX(curTx); 11109}); 11110document.querySelectorAll('[data-tc-filter]').forEach(function(b){ 11111 b.addEventListener('click', function(){ 11112 tcFilter = b.getAttribute('data-tc-filter'); 11113 document.querySelectorAll('[data-tc-filter]').forEach(function(x){ x.classList.remove('active'); }); 11114 b.classList.add('active'); 11115 renderTC(); 11116 }); 11117}); 11118 11119// Auth disabled for testing - topbar user icon is decorative 11120ge('btnExportCSV').addEventListener('click', exportCSV); 11121ge('btnImportCSV').addEventListener('click', openImport); 11122ge('btnConfirmImport').addEventListener('click', confirmImport); 11123 11124// CSV drop zone 11125var dropZone = ge('csvDropZone'); 11126dropZone.addEventListener('click', function(){ ge('csvFileInput').click(); }); 11127dropZone.addEventListener('dragover', function(e){ e.preventDefault(); this.style.borderColor='var(--accent)'; this.style.background='rgba(201,168,76,0.05)'; }); 11128dropZone.addEventListener('dragleave', function(){ this.style.borderColor='var(--border)'; this.style.background=''; }); 11129dropZone.addEventListener('drop', function(e){ 11130 e.preventDefault(); this.style.borderColor='var(--border)'; this.style.background=''; 11131 var file = e.dataTransfer.files[0]; 11132 if(file) readCSVFile(file); 11133}); 11134ge('csvFileInput').addEventListener('change', function(){ 11135 if(this.files[0]) readCSVFile(this.files[0]); 11136 this.value = ''; 11137}); 11138 11139function readCSVFile(file){ 11140 var reader = new FileReader(); 11141 reader.onload = function(e){ 11142 var contacts = parseCSV(e.target.result); 11143 if(!contacts.length){ alert('No valid contacts found. Check that your CSV has headers and data rows.'); return; } 11144 showCSVPreview(contacts); 11145 }; 11146 reader.readAsText(file); 11147} 11148document.querySelectorAll('[data-close]').forEach(function(b){b.addEventListener('click',function(){cm(b.getAttribute('data-close'));});}); 11149document.querySelectorAll('.modal-ov').forEach(function(m){m.addEventListener('click',function(e){if(e.target===m)m.classList.remove('open');});}); 11150document.querySelectorAll('.filter-btn').forEach(function(b){b.addEventListener('click',function(){curFilter=b.getAttribute('data-filter');document.querySelectorAll('.filter-btn').forEach(function(x){x.classList.remove('active');});b.classList.add('active');rc();});}); 11151 11152startup(); 11153// ===================== DRIP CAMPAIGNS MODULE ===================== 11154// State model: 11155// CAMP: [{id, name, active, trigger, steps:[{subject, body, delayDays}], created_at}] 11156// ENR: [{id, campaignId, contactId, fromMemberId, stepIndex, status, nextSendAt, created_at}] 11157// status: 'active' | 'completed' | 'stopped' 11158// SENDLOG: [{id, enrollmentId, campaignId, contactId, stepIndex, subject, to, status, sentAt, error, created_at}] 11159 11160function dripSave(table, rows){ sv(); if(supaReady) dbSave(table, rows); } 11161function dripDel(table, id){ if(supaReady) dbDelete(table, id); } 11162 11163// ---- merge tags ---- 11164function dripMerge(text, c){ 11165 if(!text) return ''; 11166 var agent = 'Randy Baker'; 11167 return text 11168 .replace(/\{\{\s*first\s*\}\}/gi, (c&&c.first)||'there') 11169 .replace(/\{\{\s*last\s*\}\}/gi, (c&&c.last)||'') 11170 .replace(/\{\{\s*property\s*\}\}/gi, (c&&c.property)||'your property search') 11171 .replace(/\{\{\s*price\s*\}\}/gi, (c&&c.price)||'') 11172 .replace(/\{\{\s*agent\s*\}\}/gi, agent); 11173} 11174 11175function dripDayMs(){ return 86400000; } 11176function nowISO(){ return new Date().toISOString(); } 11177 11178// ---- enrollment creation ---- 11179function enrollContact(campaignId, contactId, fromMemberId){ 11180 var camp = CAMP.find(function(x){ return String(x.id)===String(campaignId); }); 11181 if(!camp || !camp.steps || !camp.steps.length) return null; 11182 // avoid duplicate active enrollment in same campaign 11183 var dup = ENR.find(function(e){ return String(e.campaignId)===String(campaignId) && String(e.contactId)===String(contactId) && e.status==='active'; }); 11184 if(dup) return dup;
11185 var firstDelay = parseInt(camp.steps[0].delayDays)||0; 11186 var enr = { 11187 id: Date.now() + Math.floor(Math.random()*1000), 11188 campaignId: campaignId, 11189 contactId: contactId, 11190 fromMemberId: fromMemberId || (typeof gmailState!=='undefined' ? gmailState.activeMemberId : null) || null, 11191 stepIndex: 0, 11192 status: 'active', 11193 nextSendAt: new Date(Date.now() + firstDelay*dripDayMs()).toISOString(), 11194 created_at: nowISO() 11195 }; 11196 ENR.push(enr); 11197 dripSave('enrollments', [enr]); 11198 return enr; 11199} 11200 11201// ---- auto-enroll on stage change ---- 11202function autoEnrollOnStage(c, stage){ 11203 if(!c) return; 11204 CAMP.forEach(function(camp){ 11205 if(camp.active === false || camp.active === 'false') return; 11206 if(camp.trigger === ('stage:' + stage)){ 11207 enrollContact(camp.id, c.id, null); 11208 } 11209 }); 11210 updateDripBadges(); 11211} 11212 11213// ---- process due steps (called on open, manual run, and 30s poll) ---- 11214function processDrips(){ 11215 var due = ENR.filter(function(e){ 11216 return e.status==='active' && e.nextSendAt && new Date(e.nextSendAt) <= new Date(); 11217 }); 11218 due.forEach(function(e){ dripFireStep(e); }); 11219 updateDripBadges(); 11220} 11221 11222function dripFireStep(enr){ 11223 var camp = CAMP.find(function(x){ return String(x.id)===String(enr.campaignId); }); 11224 var c = gc(typeof enr.contactId==='string' ? (parseInt(enr.contactId)||enr.contactId) : enr.contactId); 11225 if(!camp || !c){ enr.status='stopped'; dripSave('enrollments',[enr]); return; } 11226 var step = camp.steps[enr.stepIndex]; 11227 if(!step){ enr.status='completed'; dripSave('enrollments',[enr]); return; } 11228 11229 var subject = dripMerge(step.subject, c); 11230 var body = dripMerge(step.body, c); 11231 var to = c.email; 11232 11233 var logEntry = { 11234 id: Date.now() + Math.floor(Math.random()*1000), 11235 enrollmentId: enr.id, campaignId: camp.id, contactId: c.id, 11236 stepIndex: enr.stepIndex, subject: subject, to: to||'', 11237 status: 'pending', sentAt: null, error: '', created_at: nowISO() 11238 }; 11239 11240 function advance(){ 11241 enr.stepIndex += 1; 11242 if(enr.stepIndex >= camp.steps.length){ 11243 enr.status = 'completed'; 11244 enr.nextSendAt = null; 11245 }else{ 11246 var nextDelay = parseInt(camp.steps[enr.stepIndex].delayDays)||0; 11247 enr.nextSendAt = new Date(Date.now() + nextDelay*dripDayMs()).toISOString(); 11248 } 11249 dripSave('enrollments', [enr]); 11250 if(curPage==='drips') renderDrips(); 11251 updateDripBadges(); 11252 } 11253 11254 if(!to){ 11255 logEntry.status='failed'; logEntry.error='No email address on contact'; 11256 SENDLOG.push(logEntry); dripSave('send_log',[logEntry]); 11257 enr.status='stopped'; dripSave('enrollments',[enr]); return; 11258 } 11259 var fromMember = enr.fromMemberId || (typeof gmailState!=='undefined' ? gmailState.activeMemberId : null); 11260 if(!fromMember || typeof sendEmail!=='function'){ 11261 // Gmail not connected -> leave queued, do not advance 11262 logEntry.status='queued'; logEntry.error='Gmail account not connected - will retry'; 11263 SENDLOG.push(logEntry); dripSave('send_log',[logEntry]); 11264 // push nextSendAt forward slightly so we retry next cycle but don't spin 11265 enr.nextSendAt = new Date(Date.now() + 60000).toISOString(); 11266 dripSave('enrollments',[enr]); 11267 return; 11268 } 11269 11270 sendEmail({ to: to, subject: subject, body: body, memberId: fromMember }, function(){ 11271 logEntry.status='sent'; logEntry.sentAt=nowISO(); 11272 SENDLOG.push(logEntry); dripSave('send_log',[logEntry]); 11273 // log the touch on the contact's notes 11274 var nn={id:Date.now(),contactId:c.id,text:'Drip "'+camp.name+'" step '+(enr.stepIndex+1)+' sent: '+subject,date:nowISO()}; 11275 N.push(nn); if(typeof saveNote==='function') saveNote(nn); 11276 advance(); 11277 }); 11278} 11279 11280function stopEnrollment(id){ 11281 var e = ENR.find(function(x){ return String(x.id)===String(id); }); 11282 if(!e) return; 11283 e.status='stopped'; e.nextSendAt=null; 11284 dripSave('enrollments',[e]); renderDrips(); updateDripBadges(); 11285} 11286 11287// ---- badges ---- 11288function updateDripBadges(){ 11289 var active = ENR.filter(function(e){ return e.status==='active'; }).length; 11290 var nb = ge('nbDrips'); if(nb) nb.textContent = CAMP.filter(function(x){return x.active!==false&&x.active!=='false';}).length; 11291 var ne = ge('nbEnrollments'); if(ne) ne.textContent = active; 11292} 11293 11294// ---- tab switching ---- 11295function dripSwitchTab(tab){ 11296 ['campaigns','enrollments','log'].forEach(function(t){ 11297 var btn = ge('dripTab'+t.charAt(0).toUpperCase()+t.slice(1)); 11298 var pane = ge('dripPane'+t.charAt(0).toUpperCase()+t.slice(1)); 11299 var on = (t===tab); 11300 if(btn){ btn.style.borderBottomColor = on?'var(--accent)':'transparent'; btn.style.color = on?'var(--accent)':'var(--text3)'; btn.classList.toggle('active', on); } 11301 if(pane){ pane.style.display = on?'block':'none'; } 11302 }); 11303 renderDrips(); 11304} 11305 11306// ===================== RENDER ===================== 11307function renderDrips(){ 11308 renderCampaignList(); 11309 renderEnrollmentList(); 11310 renderSendLog(); 11311 updateDripBadges(); 11312} 11313 11314function mkBtnEl(cls, txt, style){ var b=document.createElement('button'); b.className=cls; b.textContent=txt; if(style)b.style.cssText=style; return b; } 11315 11316function renderCampaignList(){ 11317 var wrap = ge('dripCampaignList'); if(!wrap) return; 11318 wrap.textContent=''; 11319 if(!CAMP.length){ 11320 wrap.appendChild(mkDiv('font-size:16px;color:var(--text3);padding:20px;text-align:center;
11320','No campaigns yet. Create one to start automating follow-up emails.')); 11321 return; 11322 } 11323 CAMP.forEach(function(camp){ 11324 var enrolled = ENR.filter(function(e){ return String(e.campaignId)===String(camp.id) && e.status==='active'; }).length; 11325 var isActive = camp.active!==false && camp.active!=='false'; 11326 var row = mkRow('li'); row.style.cssText='align-items:flex-start;gap:12px;padding:14px 0;border-bottom:1px solid var(--border);'; 11327 11328 var left = mkDiv('flex:1;'); 11329 var titleRow = mkDiv('display:flex;align-items:center;gap:10px;flex-wrap:wrap;'); 11330 titleRow.appendChild(mkDiv('font-size:18px;font-weight:700;color:var(--text);', camp.name)); 11331 var badge = mkDiv('font-size:13px;font-weight:600;padding:2px 9px;border-radius:20px;'+(isActive?'background:rgba(107,201,122,0.15);color:var(--lead);':'background:var(--surface2);color:var(--text3);'), isActive?'ACTIVE':'PAUSED'); 11332 titleRow.appendChild(badge); 11333 left.appendChild(titleRow); 11334 var meta = (camp.steps?camp.steps.length:0)+' step'+((camp.steps&&camp.steps.length===1)?'':'s')+' - '+enrolled+' active enrolled'; 11335 if(camp.trigger){ meta += ' - auto: '+camp.trigger.replace('stage:','stage = '); } 11336 left.appendChild(mkDiv('font-size:15px;color:var(--text3);margin-top:4px;', meta)); 11337 row.appendChild(left); 11338 11339 var acts = mkDiv('display:flex;gap:7px;flex-wrap:wrap;'); 11340 var bEnroll = mkBtnEl('btn btn-p','Enroll','padding:5px 11px;font-size:15px;'); 11341 (function(id){ bEnroll.addEventListener('click', function(){ openEnrollModal(id); }); })(camp.id); 11342 var bEdit = mkBtnEl('btn btn-g','Edit','padding:5px 11px;font-size:15px;'); 11343 (function(id){ bEdit.addEventListener('click', function(){ openCampaignModal(id); }); })(camp.id); 11344 var bToggle = mkBtnEl('btn btn-g', isActive?'Pause':'Resume','padding:5px 11px;font-size:15px;'); 11345 (function(id){ bToggle.addEventListener('click', function(){ toggleCampaign(id); }); })(camp.id); 11346 var bDel = mkBtnEl('btn btn-g','Delete','padding:5px 11px;font-size:15px;color:var(--danger);'); 11347 (function(id){ bDel.addEventListener('click', function(){ deleteCampaign(id); }); })(camp.id); 11348 acts.appendChild(bEnroll); acts.appendChild(bEdit); acts.appendChild(bToggle); acts.appendChild(bDel); 11349 row.appendChild(acts); 11350 wrap.appendChild(row); 11351 }); 11352} 11353 11354function renderEnrollmentList(){ 11355 var wrap = ge('dripEnrollmentList'); if(!wrap) return; 11356 wrap.textContent=''; 11357 var active = ENR.filter(function(e){ return e.status==='active'; }); 11358 if(!active.length){ 11359 wrap.appendChild(mkDiv('font-size:16px;color:var(--text3);padding:20px;text-align:center;
11359','No active enrollments.')); 11360 return; 11361 } 11362 active.sort(function(a,b){ return new Date(a.nextSendAt||0)-new Date(b.nextSendAt||0); }); 11363 active.forEach(function(e){ 11364 var camp = CAMP.find(function(x){ return String(x.id)===String(e.campaignId); }); 11365 var c = gc(typeof e.contactId==='string'?(parseInt(e.contactId)||e.contactId):e.contactId); 11366 var row = mkRow('li'); row.style.cssText='align-items:center;gap:12px;padding:12px 0;border-bottom:1px solid var(--border);'; 11367 var info = mkDiv('flex:1;'); 11368 info.appendChild(mkDiv('font-size:17px;font-weight:600;color:var(--text);', (c?fn(c):'Unknown contact'))); 11369 var total = camp&&camp.steps?camp.steps.length:0; 11370 var when = e.nextSendAt ? fmtDripWhen(e.nextSendAt) : '-'; 11371 info.appendChild(mkDiv('font-size:15px;color:var(--text3);margin-top:3px;', (camp?camp.name:'(deleted)')+' - step '+(e.stepIndex+1)+' of '+total+' - next: '+when)); 11372 row.appendChild(info); 11373 var bStop = mkBtnEl('btn btn-g','Stop','padding:5px 11px;font-size:15px;color:var(--danger);'); 11374 (function(id){ bStop.addEventListener('click', function(){ stopEnrollment(id); }); })(e.id); 11375 row.appendChild(bStop); 11376 wrap.appendChild(row); 11377 }); 11378} 11379
11380function renderSendLog(){ 11381 var wrap = ge('dripLogList'); if(!wrap) return; 11382 wrap.textContent=''; 11383 if(!SENDLOG.length){ 11384 wrap.appendChild(mkDiv('font-size:16px;color:var(--text3);padding:20px;text-align:center;','Nothing sent yet.')); 11385 return; 11386 } 11387 var rows = SENDLOG.slice().sort(function(a,b){ return new Date(b.created_at||0)-new Date(a.created_at||0); }).slice(0,100); 11388 rows.forEach(function(l){ 11389 var c = gc(typeof l.contactId==='string'?(parseInt(l.contactId)||l.contactId):l.contactId); 11390 var camp = CAMP.find(function(x){ return String(x.id)===String(l.campaignId); }); 11391 var row = mkRow('li'); row.style.cssText='align-items:center;gap:12px;padding:11px 0;border-bottom:1px solid var(--border);'; 11392 var dot = mkDiv('width:8px;height:8px;border-radius:50%;flex-shrink:0;'+(l.status==='sent'?'background:var(--lead);':l.status==='failed'?'background:var(--danger);':'background:var(--accent);'),''); 11393 row.appendChild(dot); 11394 var info = mkDiv('flex:1;'); 11395 info.appendChild(mkDiv('font-size:16px;font-weight:600;color:var(--text);', l.subject||'(no subject)')); 11396 var sub = (c?fn(c):'?')+' - '+(camp?camp.name:'?')+' - '+(l.status==='sent'?('sent '+fmtDripWhen(l.sentAt||l.created_at)):l.status)+(l.error?(' ('+l.error+')'):''); 11397 info.appendChild(mkDiv('font-size:14px;color:var(--text3);margin-top:3px;', sub)); 11398 row.appendChild(info); 11399 wrap.appendChild(row); 11400 }); 11401} 11402 11403function fmtDripWhen(iso){ 11404 if(!iso) return '-'; 11405 var d = new Date(iso); var now = new Date(); 11406 var diff = d - now; 11407 var days = Math.round(diff/dripDayMs()); 11408 var dateStr = d.toLocaleDateString('en-US',{month:'short',day:'numeric'}); 11409 if(diff < 0) return dateStr+' (overdue)'; 11410 if(days===0) return dateStr+' (today)'; 11411 if(days===1) return dateStr+' (tomorrow)'; 11412 return dateStr+' (in '+days+'d)'; 11413} 11414 11415// ===================== CAMPAIGN EDITOR ===================== 11416function buildStepRow(step, idx){ 11417 step = step || {subject:'',body:'',delayDays:idx===0?0:3}; 11418 var box = document.createElement('div'); 11419 box.className='drip-step-box'; 11420 box.style.cssText='border:1px solid var(--border);border-radius:8px;padding:12px;margin-bottom:10px;background:var(--surface2);'; 11421 var hdr = mkDiv('display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;'); 11422 hdr.appendChild(mkDiv('font-size:15px;font-weight:700;color:var(--accent);','Step '+(idx+1))); 11423 var del = mkBtnEl('btn btn-g','Remove','padding:3px 9px;font-size:14px;color:var(--danger);'); 11424 del.addEventListener('click', function(){ box.parentNode.removeChild(box); renumberSteps(); }); 11425 hdr.appendChild(del); 11426 box.appendChild(hdr); 11427 11428 var delayFg = mkDiv('margin-bottom:8px;'); 11429 var delayLbl = document.createElement('label'); delayLbl.className='fl'; delayLbl.textContent = idx===0 ? 'Send delay (days after enrollment)' : 'Wait (days after previous step)'; 11430 var delayInp = document.createElement('input'); delayInp.className='fi drip-step-delay'; delayInp.type='number'; delayInp.min='0'; delayInp.value=(step.delayDays!=null?step.delayDays:(idx===0?0:3)); delayInp.style.cssText='width:120px;'; 11431 delayFg.appendChild(delayLbl); delayFg.appendChild(delayInp); box.appendChild(delayFg); 11432 11433 var subjFg = mkDiv('margin-bottom:8px;'); 11434 var subjLbl = document.createElement('label'); subjLbl.className='fl'; subjLbl.textContent='Subject'; 11435 var subjInp = document.createElement('input'); subjInp.className='fi drip-step-subject'; subjInp.placeholder='Hi {{first}}, following up'; subjInp.value=step.subject||''; 11436 subjFg.appendChild(subjLbl); subjFg.appendChild(subjInp); box.appendChild(subjFg); 11437 11438 var bodyFg = mkDiv(''); 11439 var bodyLbl = document.createElement('label'); bodyLbl.className='fl'; bodyLbl.textContent='Body'; 11440 var bodyTA = document.createElement('textarea'); bodyTA.className='fi drip-step-body'; bodyTA.rows=5; bodyTA.placeholder='Hi {{first}},\n\nThanks for your interest in {{property}}...\n\n- {{agent}}'; bodyTA.value=step.body||''; 11441 bodyFg.appendChild(bodyLbl); bodyFg.appendChild(bodyTA); box.appendChild(bodyFg); 11442 11443 return box; 11444} 11445 11446function renumberSteps(){ 11447 var boxes = ge('campStepsWrap').querySelectorAll('.drip-step-box'); 11448 for(var i=0;i<boxes.length;i++){ 11449 boxes[i].querySelector('div > div').textContent = 'Step '+(i+1); 11450 var lbl = boxes[i].querySelector('label.fl'); 11451 if(lbl) lbl.textContent = i===0 ? 'Send delay (days after enrollment)' : 'Wait (days after previous step)'; 11452 } 11453} 11454 11455function openCampaignModal(id){ 11456 var wrap = ge('campStepsWrap'); wrap.textContent=''; 11457 if(id){ 11458 var camp = CAMP.find(function(x){ return String(x.id)===String(id); }); 11459 ge('campModalTitle').textContent='Edit Campaign'; 11460 ge('btnSaveCampaign').setAttribute('data-edit-id', id); 11461 ge('campName').value = camp.name||''; 11462 ge('campActive').value = (camp.active!==false && camp.active!=='false') ? 'true' : 'false'; 11463 ge('campTrigger').value = camp.trigger||'';
11464 (camp.steps||[]).forEach(function(s,i){ wrap.appendChild(buildStepRow(s,i)); }); 11465 if(!camp.steps || !camp.steps.length) wrap.appendChild(buildStepRow(null,0)); 11466 }else{ 11467 ge('campModalTitle').textContent='New Campaign'; 11468 ge('btnSaveCampaign').setAttribute('data-edit-id',''); 11469 ge('campName').value=''; ge('campActive').value='true'; ge('campTrigger').value=''; 11470 wrap.appendChild(buildStepRow(null,0)); 11471 } 11472 om('campaignModal'); 11473} 11474 11475function addCampaignStep(){ 11476 var wrap = ge('campStepsWrap'); 11477 var idx = wrap.querySelectorAll('.drip-step-box').length; 11478 wrap.appendChild(buildStepRow(null, idx)); 11479} 11480 11481function saveCampaign(){ 11482 var name = ge('campName').value.trim(); 11483 if(!name){ alert('Please enter a campaign name.'); return; } 11484 var boxes = ge('campStepsWrap').querySelectorAll('.drip-step-box'); 11485 var steps = []; 11486 for(var i=0;i<boxes.length;i++){ 11487 var subj = boxes[i].querySelector('.drip-step-subject').value.trim(); 11488 var body = boxes[i].querySelector('.drip-step-body').value.trim(); 11489 var delay
11489= parseInt(boxes[i].querySelector('.drip-step-delay').value)||0; 11490 if(!subj && !body) continue; 11491 steps.push({subject:subj, body:body, delayDays:delay}); 11492 } 11493 if(!steps.length){ alert('Add at least one email step with a subject or body.'); return; } 11494 var editId = ge('btnSaveCampaign').getAttribute('data-edit-id'); 11495 var camp; 11496 if(editId){ 11497 camp = CAMP.find(function(x){ return String(x.id)===String(editId); }); 11498 camp.name=name; camp.active=(ge('campActive').value==='true'); camp.trigger=ge('campTrigger').value; camp.steps=steps; 11499 }else{ 11500 camp = { id: Date.now(), name:name, active:(ge('campActive').value==='true'), trigger:ge('campTrigger').value, steps:steps, created_at:nowISO() }; 11501 CAMP.push(camp); 11502 } 11503 // Supabase stores steps as jsonb; send a copy with steps serialized for safety 11504 var toSave = JSON.parse(JSON.stringify(camp)); 11505 dripSave('campaigns', [toSave]); 11506 cm('campaignModal'); 11507 renderDrips(); 11508} 11509 11510function toggleCampaign(id){ 11511 var camp = CAMP.find(function(x){ return String(x.id)===String(id); }); 11512 if(!camp) return; 11513 camp.active = !(camp.active!==false && camp.active!=='false'); 11514 dripSave('campaigns', [JSON.parse(JSON.stringify(camp))]); 11515 renderDrips(); 11516} 11517 11518function deleteCampaign(id){ 11519 if(!confirm('Delete this campaign? Active enrollments will be stopped.')) return; 11520 ENR.forEach(function(e){ if(String(e.campaignId)===String(id) && e.status==='active'){ e.status='stopped'; e.nextSendAt=null; dripSave('enrollments',[e]); } }); 11521 CAMP = CAMP.filter(function(x){ return String(x.id)!==String(id); }); 11522 dripDel('campaigns', id); 11523 sv(); 11524 renderDrips(); 11525} 11526 11527// ===================== ENROLL MODAL ===================== 11528async function openEnrollModal(presetCampaignId, presetContactId){ 11529 // Connected Gmail accounts are only loaded by initGmail(), which only runs when you visit 11530 // the Gmail tab. Without this, enrolling from anywhere else says "No Gmail connected". 11531 if(typeof gmailState !== 'undefined' && !Object.keys(gmailState.connectedAccounts || {}).length){ 11532 try{ await checkConnectedAccounts(); }catch(e){} 11533 } 11534 var cs = ge('enrollCampaign'); cs.textContent=''; 11535 CAMP.filter(function(x){ return x.active!==false && x.active!=='false'; }).forEach(function(camp){ 11536 var o=document.createElement('option'); o.value=camp.id; o.textContent=camp.name; cs.appendChild(o); 11537 }); 11538 if(presetCampaignId) cs.value = presetCampaignId; 11539 11540 var enCP = ge('enrollContactPick'); enCP.innerHTML=''; 11541 var enPick = buildContactPicker(enCP, 'enrollContact', 'Search contact by name, email, phone...'); 11542 if(presetContactId){ var _pc = gc(presetContactId); if(_pc) enPick.setContact(_pc); } 11543 11544 var fs = ge('enrollFrom'); fs.textContent=''; 11545 var accts = (typeof gmailState!=='undefined' && gmailState.connectedAccounts) ? gmailState.connectedAccounts : {}; 11546 var keys = Object.keys(accts); 11547 if(!keys.length){ 11548 var o=document.createElement('option'); o.value=''; o.textContent='No Gmail connected (will queue)'; fs.appendChild(o); 11549 }else{ 11550 keys.forEach(function(k){ var o=document.createElement('option'); o.value=k; o.textContent=(accts[k].email||accts[k].name||k); fs.appendChild(o); }); 11551 if(gmailState.activeMemberId) fs.value=gmailState.activeMemberId; 11552 } 11553 om('enrollModal'); 11554} 11555 11556function confirmEnroll(){ 11557 var campaignId = ge('enrollCampaign').value; 11558 var contactId = ge('enrollContact').value; 11559 var fromMember = ge('enrollFrom').value || null; 11560 if(!campaignId){ alert('Select a campaign.'); return; } 11561 if(!contactId){ alert('Select a contact.'); return; } 11562 var cid = parseInt(contactId)||contactId; 11563 var c = gc(cid); 11564 if(c && !c.email){ if(!confirm('This contact has no email address. Enroll anyway? Steps will fail until an email is added.')){ return; } } 11565 var enr = enrollContact(campaignId, cid, fromMember); 11566 if(!enr){ alert('Could not enroll - campaign has no steps.'); return; } 11567 cm('enrollModal'); 11568 processDrips(); 11569 renderDrips(); 11570} 11571 11572// ===================== WIRING ===================== 11573ge('nav-drips') && (function(){ 11574 ge('btnNewCampaign') && ge('btnNewCampaign').addEventListener('click', function(){ openCampaignModal(null); }); 11575 ge('btnAddStep') && ge('btnAddStep').addEventListener('click', addCampaignStep); 11576 ge('btnSaveCampaign') && ge('btnSaveCampaign').addEventListener('click', saveCampaign); 11577 ge('btnConfirmEnroll') && ge('btnConfirmEnroll').addEventListener('click', confirmEnroll); 11578 ge('btnRunDripsNow') && ge('btnRunDripsNow').addEventListener('click', function(){ processDrips(); renderDrips(); }); 11579 ['campaigns','enrollments','log'].forEach(function(t){ 11580 var btn = ge('dripTab'+t.charAt(0).toUpperCase()+t.slice(1)); 11581 if(btn) btn.addEventListener('click', function(){ dripSwitchTab(t); }); 11582 }); 11583 // close handlers for new modals reuse existing data-close convention; ensure overlay click closes 11584 ['campaignModal','enrollModal'].forEach(function(mid){ 11585 var m = ge(mid); 11586 if(m) m.addEventListener('click', function(e){ if(e.target===m) cm(mid); }); 11587 }); 11588})(); 11589 11590// Run a processing pass shortly after load and on the existing poll cycle 11591setTimeout(function(){ try{ processDrips(); updateDripBadges(); }catch(e){ console.log('drip init', e); } }, 4000); 11592// ===================== END DRIP CAMPAIGNS MODULE ===================== 11593 11594// ============================================================================ 11595// INVESTMENTS â rental / investment property tracking 11596// ============================================================================ 11597var INV_INCOME_CATS = ['Rent','Other income']; 11598var INV_EXPENSE_CATS = ['Mortgage','HOA','Utilities','Insurance','Property Tax','Repairs','Management','Other']; 11599var _invMonth = null; // 'YYYY-MM' selected dashboard month; null = current month 11600 11601function invNum(v){ var n=parseFloat(String(v==null?'':v).replace(/[^0-9.\-]/g,'')); return isNaN(n)?0:n; } 11602function invMoney(n){ n=invNum(n);
11602 return (n<0?'-$':'$')+Math.abs(n).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2}); } 11603function invMonthKeyOf(d){ d=d?new Date(d):new Date(); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0'); } 11604function invCurMonth(){ return _invMonth || invMonthKeyOf(); } 11605function invMonthLabel(ym){ var p=ym.split('-'); return new Date(+p[0],+p[1]-1,1).toLocaleDateString('en-US',{month:'long',year:'numeric'}); } 11606function invShiftMonth(delta){ var p=invCurMonth().split('-'); var d=new Date(+p[0],+p[1]-1+delta,1); _invMonth=invMonthKeyOf(d); renderInvestments(); } 11607function invDirFor(cat){ return INV_INCOME_CATS.indexOf(cat)>=0 ? 'income' : 'expense'; } 11608function ledInMonth(l,ym){ return (l.date||'').slice(0,7)===ym; } 11609 11610function hoaMonthlyEquiv(h){ var a=invNum(h.dues_amount); var f=(h.dues_frequency||'monthly'); return f==='annual'?a/12 : f==='quarterly'?a/3 : a; } 11611function hoaDueThisMonth(h,ym){ var m=+ym.split('-')[1]; var f=(h.dues_frequency||'monthly'); if(f==='monthly')return true; if(f==='quarterly')return (m%3===1); if(f==='annual')return (m===1); return true; } 11612 11613// Expected income/expenses for a month (monthly-equivalent). 11614function invExpected(ym){ 11615 var income=0, expenses=0; 11616 IUNIT.forEach(function(u){ income += invNum(u.rent_amount); }); 11617 IPROP.forEach(function(p){ expenses += invNum(p.mortgage_payment); }); 11618 IHOA.forEach(function(h){ expenses += hoaMonthlyEquiv(h); }); 11619 return { income:income, expenses:expenses }; 11620} 11621function invActual(ym){ 11622 var income=0, expenses=0; 11623 ILED.forEach(function(l){ if(!ledInMonth(l,ym)) return; if((l.direction||invDirFor(l.category))==='income') income+=invNum(l.amount); else expenses+=invNum(l.amount); }); 11624 return { income:income, expenses:expenses }; 11625} 11626// Late / missing detection for a month: expected charges with no matching ledger entry. 11627// Expected monthly rent for a property = sum of its occupied units' rent. 11628function propExpectedRent(pid){ return unitsForProp(pid).reduce(function(s,u){ return s + ((u.status||'occupied')==='vacant'?0:invNum(u.rent_amount)); }, 0); } 11629// ---- Editable per-unit terms -> expected figures. All value-agnostic: the dollar amounts live on 11630// the unit (rent_amount, mgmt_fee, passthrough_amount), so a rent/fee change is data, not code. 11631function unitOccupied(u){ return (u.status||'occupied')!=='vacant'; } 11632// What should land in the account for this unit: rent - management fee + pass-through (e.g. parking). 11633function unitExpectedDeposit(u){ return unitOccupied(u) ? (invNum(u.rent_amount)-invNum(u.mgmt_fee)+invNum(u.passthrough_amount)) : 0; } 11634// True profit contribution: the pass-through washes (we hand it to the HOA), so profit = rent - mgmt. 11635function unitExpectedProfit(u){ return unitOccupied(u) ? (invNum(u.rent_amount)-invNum(u.mgmt_fee)) : 0; } 11636function propExpectedDeposit(pid){ return unitsForProp(pid).reduce(function(s,u){ return s+unitExpectedDeposit(u); },0); } 11637function propExpectedProfit(pid){ return unitsForProp(pid).reduce(function(s,u){ return s+unitExpectedProfit(u); },0); } 11638// What actually landed for rent in a month = rent-type income minus the manager's deductions 11639// (management fee + any repairs the manager took out of the deposit). This equals the real deposit, 11640// so a repair pulls "received" down and the shortfall equals the repair. Robust whether the manager 11641// posts one net deposit or gross rent plus separate management/repair lines. 11642function propRentReceived(pid, ym){ 11643 var inc=0, ded=0; 11644 ILED.forEach(function(l){ 11645 if(String(l.property_id)!==String(pid) || !ledInMonth(l,ym)) return; 11646 var cat=l.category||''; 11647 if(INV_INCOME_CATS.indexOf(cat)>=0) inc+=invNum(l.amount); 11648 else if(cat==='Management' || cat==='Repairs') ded+=invNum(l.amount); 11649 }); 11650 return inc-ded; 11651} 11652// A deposit below the unit's expected amount is a repair/maintenance deduction - but only once rent 11653// has actually come in ($0 means "not received yet", which is a different flag, not a repair). 11654function propRentShortfall(pid, ym){ 11655 var exp=propExpectedDeposit(pid); if(exp<=0) return 0; 11656 var got=propRentReceived(pid, ym); 11657 return (got>0 && got<exp) ? (exp-got) : 0; 11658} 11659function invFlags(ym){ 11660 var flags=[]; 11661 function paid(pred){ return ILED.some(function(l){ return ledInMonth(l,ym) && pred(l); }); } 11662 // Rent is checked per PROPERTY (rent may be logged at the property level, not per unit). 11663 IPROP.forEach(function(p){ 11664 if(propExpectedRent(p.id) <= 0) return; 11665 var got = paid(function(l){ return l.category==='Rent' && String(l.property_id)===String(p.id); }); 11666 if(!got) flags.push({level:'red', text:'Rent not received â '+(p.name||p.address)+' ('+invMoney(propExpectedRent(p.id))+')'}); 11667 }); 11668 IPROP.forEach(function(p){ 11669 if(invNum(p.mortgage_payment)<=0) return; 11670 var got = paid(function(l){ return l.category==='Mortgage' && String(l.property_id)===String(p.id); }); 11671 if(!got) flags.push({level:'warn', text:'Mortgage not logged â '+(p.name||p.address)+' ('+invMoney(p.mortgage_payment)+')'}); 11672 }); 11673 IHOA.forEach(function(h){ 11674 if(invNum(h.dues_amount)<=0 || !hoaDueThisMonth(h,ym)) return; 11675 var got = paid(function(l){ return l.category==='HOA' && String(l.hoa_id)===String(h.id); }); 11676 if(!got) flags.push({level:'warn', text:'HOA not logged â '+(h.name||('account '+(h.account_number||'')))+' ('+invMoney(h.dues_amount)+')'}); 11677 }); 11678 return flags; 11679} 11680function propNet(pid, ym){ 11681 var net=0;
11682 ILED.forEach(function(l){ if(String(l.property_id)!==String(pid) || !ledInMonth(l,ym)) return; net += ((l.direction||invDirFor(l.category))==='income'?1:-1)*invNum(l.amount); }); 11683 return net; 11684} 11685function updateInvBadge(){ var el=ge('nbInv'); if(el) el.textContent = invFlags(invCurMonth()).length; } 11686 11687// ---- Generic form modal (built on the fly) ---- 11688function invOpenForm(title, fields, initial, onSubmit){ 11689 initial = initial || {}; 11690 var ov=document.createElement('div'); ov.className='modal-ov open'; ov.style.zIndex='1300'; 11691 var m=document.createElement('div'); m.className='modal'; m.style.maxWidth='560px'; 11692 var h=document.createElement('div'); h.style.cssText='display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border);'; 11693 h.innerHTML='<div style="font-weight:700;font-size:17px;">'+_esc(title)+'</div>'; 11694 var x=document.createElement('button'); x.textContent='â'; x.style.cssText='background:none;border:none;font-size:18px;color:var(--text3);cursor:pointer;'; x.addEventListener('click',function(){ document.body.removeChild(ov); }); 11695 h.appendChild(x); m.appendChild(h); 11696 var body=document.createElement('div'); body.style.cssText='padding:16px 20px;display:flex;flex-direction:column;gap:12px;max-height:70vh;overflow:auto;'; 11697 var inputs={}; 11698 fields.forEach(function(f){ 11699 var wrap=document.createElement('div'); wrap.style.cssText='display:flex;flex-direction:column;gap:4px;'; 11700 var lb=document.createElement('label'); lb.className='fl'; lb.textContent=f.label+(f.required?' *':''); wrap.appendChild(lb); 11701 var el; 11702 if(f.type==='select'){ 11703 el=document.createElement('select'); el.className='fsel'; 11704 (f.options||[]).forEach(function(o){ var op=document.createElement('option'); op.value=o.value; op.textContent=o.label; el.appendChild(op); }); 11705 el.value = (initial[f.key]!=null?initial[f.key]:(f.def!=null?f.def:'')); 11706 } else if(f.type==='textarea'){ 11707 el=document.createElement('textarea'); el.className='fi'; el.rows=2; el.value=initial[f.key]!=null?initial[f.key]:''; 11708 } else { 11709 el=document.createElement('input'); el.className='fi'; el.type=(f.type||'text'); if(f.placeholder) el.placeholder=f.placeholder; 11710 el.value=initial[f.key]!=null?initial[f.key]:''; 11711 } 11712 if(f.onChange) el.addEventListener('change', function(){ f.onChange(el.value, inputs); }); 11713 inputs[f.key]=el; wrap.appendChild(el); body.appendChild(wrap); 11714 }); 11715 m.appendChild(body); 11716 var ft=document.createElement('div'); ft.style.cssText='display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--border);'; 11717 var save=document.createElement('button'); save.className='btn btn-p'; save.textContent='Save'; 11718 save.addEventListener('click', function(){ 11719 var vals={}; Object.keys(inputs).forEach(function(k){ vals[k]=inputs[k].value; }); 11720 var ok = onSubmit(vals, inputs, ov); 11721 if(ok!==false){ try{ document.body.removeChild(ov); }catch(e){} } 11722 }); 11723 ft.appendChild(save); m.appendChild(ft); 11724 ov.appendChild(m); ov.addEventListener('click', function(e){ if(e.target===ov) document.body.removeChild(ov); }); 11725 document.body.appendChild(ov); 11726 return inputs; 11727} 11728function invPropOptions(){ return [{value:'',label:'â none â'}].concat(IPROP.map(function(p){ return {value:String(p.id), label:(p.name||p.address||('Property '+p.id))}; })); } 11729function invHoaOptions(){ return [{value:'',label:'â none â'}].concat(IHOA.map(function(h){ return {value:String(h.id), label:(h.name||('HOA '+(h.account_number||h.id)))}; })); } 11730 11731function openInvHoaForm(h){ 11732 invOpenForm(h?'Edit HOA account':'Add HOA account', [ 11733 {key:'name',label:'HOA name',required:true,placeholder:'e.g. Maple Ridge HOA'}, 11734 {key:'account_number',label:'Account #'}, 11735 {key:'dues_amount',label:'Dues amount',type:'number',placeholder:'e.g. 250'}, 11736 {key:'dues_frequency',label:'Frequency',type:'select',options:[{value:'monthly',label:'Monthly'},{value:'quarterly',label:'Quarterly'},{value:'annual',label:'Annual'}],def:'monthly'}, 11737 {key:'due_day',label:'Due day (1-31)',type:'number'}, 11738 {key:'notes',label:'Notes',type:'textarea'} 11739 ], h, function(v){ 11740 if(!v.name.trim()){ alert('Enter an HOA name.'); return false; } 11741 var rec = h || { id: Date.now()+Math.floor(Math.random()*100000) }; 11742 rec.name=v.name.trim(); rec.account_number=v.account_number.trim(); rec.dues_amount=invNum(v.dues_amount); 11743 rec.dues_frequency=v.dues_frequency; rec.due_day=v.due_day?parseInt(v.due_day):null; rec.notes=v.notes.trim(); 11744 if(!h) IHOA.push(rec); saveInvHoa(rec); renderInvestments(); 11745 }); 11746} 11747function openInvPropertyForm(p){ 11748 invOpenForm(p?'Edit property':'Add property', [ 11749 {key:'name',label:'Property name/label',required:true,placeholder:'e.g. 123 Main'}, 11750 {key:'address',label:'Address'}, 11751 {key:'hoa_id',label:'HOA account',type:'select',options:invHoaOptions()}, 11752 {key:'purchase_price',label:'Purchase price',type:'number'}, 11753 {key:'purchase_date',label:'Purchase date',type:'date'}, 11754 {key:'mortgage_lender',label:'Mortgage lender'},
11755 {key:'mortgage_balance',label:'Mortgage balance',type:'number'}, 11756 {key:'mortgage_payment',label:'Monthly mortgage payment (P&I)',type:'number'}, 11757 {key:'mortgage_due_day',label:'Mortgage due day (1-31)',type:'number'}, 11758 {key:'notes',label:'Notes',type:'textarea'} 11759 ], p, function(v){ 11760 if(!v.name.trim()){ alert('Enter a property name.'); return false; } 11761 var rec = p || { id: Date.now()+Math.floor(Math.random()*100000) }; 11762 rec.name=v.name.trim(); rec.address=v.address.trim(); rec.hoa_id=v.hoa_id?parseInt(v.hoa_id):null; 11763 rec.purchase_price=invNum(v.purchase_price); rec.purchase_date=v.purchase_date; 11764 rec.mortgage_lender=v.mortgage_lender.trim(); rec.mortgage_balance=invNum(v.mortgage_balance); 11765 rec.mortgage_payment=invNum(v.mortgage_payment); rec.mortgage_due_day=v.mortgage_due_day?parseInt(v.mortgage_due_day):null; 11766 rec.notes=v.notes.trim(); 11767 if(!p) IPROP.push(rec); saveInvProp(rec); renderInvestments(); 11768 }); 11769} 11770function openInvUnitForm(propId, u){ 11771 invOpenForm(u?'Edit unit':'Add unit', [ 11772 {key:'label',label:'Unit label',required:true,placeholder:'Whole home / Upstairs / Downstairs'}, 11773 {key:'rent_amount',label:'Monthly rent',type:'number'}, 11774 {key:'mgmt_fee',label:'Management fee (monthly)',type:'number'}, 11775 {key:'passthrough_amount',label:'Pass-through to HOA (e.g. parking)',type:'number'}, 11776 {key:'passthrough_label',label:'Pass-through label',placeholder:'Parking'}, 11777 {key:'rent_due_day',label:'Rent due day (1-31)',type:'number'}, 11778 {key:'tenant_name',label:'Tenant name'}, 11779 {key:'status',label:'Status',type:'select',options:[{value:'occupied',label:'Occupied'},{value:'vacant',label:'Vacant'}],def:'occupied'}, 11780 {key:'lease_start',label:'Lease start',type:'date'}, 11781 {key:'lease_end',label:'Lease end',type:'date'}, 11782 {key:'notes',label:'Notes',type:'textarea'} 11783 ], u, function(v){ 11784 if(!v.label.trim()){ alert('Enter a unit label.'); return false; } 11785 var rec = u || { id: Date.now()+Math.floor(Math.random()*100000), property_id: propId }; 11786 rec.property_id = u ? u.property_id : propId; 11787 rec.label=v.label.trim(); rec.rent_amount=invNum(v.rent_amount); rec.rent_due_day=v.rent_due_day?parseInt(v.rent_due_day):null; 11788 rec.mgmt_fee=invNum(v.mgmt_fee); rec.passthrough_amount=invNum(v.passthrough_amount); rec.passthrough_label=(v.passthrough_label||'').trim(); 11789 rec.tenant_name=v.tenant_name.trim(); rec.status=v.status; rec.lease_start=v.lease_start; rec.lease_end=v.lease_end; rec.notes=v.notes.trim(); 11790 if(!u) IUNIT.push(rec); saveInvUnit(rec); renderInvestments(); 11791 }); 11792} 11793function unitOptionsFor(propId){ var o=[{value:'',label:'â whole property â'}]; unitsForProp(propId).forEach(function(u){ o.push({value:String(u.id),label:u.label}); }); return o; } 11794// ---- Approved payees (the editable expense whitelist for email ingestion) ---- 11795function openInvPayeeForm(pay, afterSave, preset){ 11796 var cats=['Utilities','HOA','Property Tax','Management','Insurance','Mortgage','Repairs','Other','Rent']; 11797 var init = pay ? { name:pay.name, match:pay.match, kind:pay.kind||'expense', category:pay.category||'Utilities', 11798 property_id:(pay.property_id!=null?String(pay.property_id):''), active:(pay.active===false?'no':'yes'), notes:pay.notes||'' } 11799 : (preset ? { name:preset.name||'', match:preset.match||'', kind:preset.kind||'expense', category:preset.category||'Utilities', property_id:'', active:'yes', notes:'' }
11799 : null); 11800 invOpenForm(pay?'Edit approved payee':'Add approved payee', [ 11801 {key:'name',label:'Name',required:true,placeholder:'e.g. Rocky Mountain Power'}, 11802 {key:'match',label:'Match text â sender, name, or subject (comma-separated for alternatives)',required:true,placeholder:'e.g. @freshstartmgmt.com, fresh start, owner statement'}, 11803 {key:'kind',label:'Type',type:'select',options:[{value:'expense',label:'Expense (we pay)'},{value:'rent',label:'Rent manager (pays us)'}],def:'expense'}, 11804 {key:'category',label:'Default category',type:'select',options:cats.map(function(c){return{value:c,label:c};}),def:'Utilities'}, 11805 {key:'property_id',label:'Pin to property (optional)',type:'select',options:invPropOptions()}, 11806 {key:'active',label:'Status',type:'select',options:[{value:'yes',label:'Active'},{value:'no',label:'Paused'}],def:'yes'}, 11807 {key:'notes',label:'Notes',type:'textarea'} 11808 ], init, function(v){ 11809 if(!v.name.trim()||!v.match.trim()){ alert('Enter a name and match text.'); return false; } 11810 var rec = pay || { id: Date.now()+Math.floor(Math.random()*100000) }; 11811 rec.name=v.name.trim(); rec.match=v.match.trim().toLowerCase(); rec.kind=v.kind||'expense'; rec.category=v.category||'Other'; 11812 rec.property_id=v.property_id?parseInt(v.property_id):null; rec.active=(v.active!=='no'); rec.notes=(v.notes||'').trim(); 11813 if(!pay) IPAYEE.push(rec); saveInvPayee(rec); if(afterSave) afterSave(); 11814 }); 11815} 11816function openInvPayeesModal(){ 11817 var ov=document.createElement('div'); ov.className='modal-ov open'; ov.style.zIndex='1280'; 11818 var m=document.createElement('div'); m.className='modal'; m.style.maxWidth='640px'; 11819 var h=document.createElement('div'); h.style.cssText='display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border);'; 11820 h.innerHTML='<div><div style="font-weight:700;font-size:17px;">Approved payees</div><div style="font-size:12px;color:var(--text3);margin-top:2px;">Only emails matching an active payee are booked. Personal receipts are ignored.</div></div>'; 11821 var x=document.createElement('button'); x.textContent='â'; x.style.cssText='background:none;border:none;font-size:18px;color:var(--text3);cursor:pointer;'; x.addEventListener('click',function(){ document.body.removeChild(ov); }); h.appendChild(x); m.appendChild(h); 11822 var body=document.createElement('div'); body.style.cssText='padding:12px 20px;max-height:60vh;overflow:auto;'; 11823 function redraw(){ 11824 body.innerHTML=''; 11825 if(!IPAYEE.length){ body.appendChild(mkDivSafe('color:var(--text3);padding:12px 0;','No approved payees yet. Add your property manager, HOA, utilities, and tax authority.')); } 11826 IPAYEE.slice().sort(function(a,b){ return String(a.name||'').localeCompare(String(b.name||'')); }).forEach(function(p){ 11827 var r=document.createElement('div'); r.style.cssText='display:flex;justify-content:space-between;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border);'; 11828 var lab='<b>'+_esc(p.name||'')+'</b> <span style="color:var(--text3);font-size:12px;">· '+_esc(p.kind||'expense')+' · '+_esc(p.category||'')+(p.active===false?' · paused':'')+'</span>' 11829 + '<div style="font-size:12px;color:var(--text3);">matches: '+_esc(p.match||'')+(p.property_id!=null?(' · '+_esc((invProp(p.property_id)||{}).name||'')):'')+'</div>'; 11830 r.innerHTML='<div>'+lab+'</div>'; 11831 var ra=document.createElement('div'); ra.style.cssText='display:flex;gap:6px;flex-shrink:0;'; 11832 var eb=document.createElement('button'); eb.className='btn btn-g'; eb.style.cssText='font-size:12px;padding:3px 9px;'; eb.textContent='Edit'; 11833 eb.addEventListener('click',function(){ openInvPayeeForm(p, redraw); }); 11834 var db=document.createElement('button'); db.className='btn btn-g'; db.style.cssText='font-size:12px;padding:3px 9px;'; db.textContent='Delete'; 11835 db.addEventListener('click',function(){ if(confirm('Delete approved payee "'+(p.name||'')+'"?')){ delInvPayee(p.id); redraw(); } }); 11836 ra.appendChild(eb); ra.appendChild(db); r.appendChild(ra); body.appendChild(r); 11837 }); 11838 } 11839 redraw(); m.appendChild(body); 11840 var ft=document.createElement('div'); ft.style.cssText='display:flex;justify-content:space-between;gap:8px;padding:14px 20px;border-top:1px solid var(--border);'; 11841 var add=document.createElement('button'); add.className='btn btn-p'; add.textContent='+ Add payee'; add.addEventListener('click',function(){ openInvPayeeForm(null, redraw); }); 11842 var done=document.createElement('button'); done.className='btn btn-g'; done.textContent='Done'; done.addEventListener('click',function(){ document.body.removeChild(ov); }); 11843 ft.appendChild(add); ft.appendChild(done); m.appendChild(ft); 11844 ov.appendChild(m); ov.addEventListener('click',function(e){ if(e.target===ov) document.body.removeChild(ov); }); 11845 document.body.appendChild(ov); 11846} 11847function openInvLedgerForm(entry, preset){ 11848 preset = preset || {}; 11849 var allCats = INV_INCOME_CATS.concat(INV_EXPENSE_CATS); 11850 var init = entry || { date: tod(), property_id: preset.property_id||'', category: preset.category||'Rent', unit_id: preset.unit_id||'', amount: preset.amount||'' }; 11851 invOpenForm(entry?'Edit entry':'Log entry', [ 11852 {key:'date',label:'Date',type:'date',required:true}, 11853 {key:'property_id',label:'Property',type:'select',options:invPropOptions()}, 11854 {key:'unit_id',label:'Unit (for rent)',type:'select',options:unitOptionsFor(init.property_id)}, 11855 {key:'category',label:'Category',type:'select',options:allCats.map(function(c){return {value:c,label:c};})}, 11856 {key:'amount',label:'Amount',type:'number',required:true}, 11857 {key:'payee',label:'Payer / Payee'}, 11858 {key:'notes',label:'Notes',type:'textarea'} 11859 ], init, function(v){ 11860 if(invNum(v.amount)<=0){ alert('Enter an amount greater than 0.'); return false; } 11861 var rec = entry || { id: Date.now()+Math.floor(Math.random()*100000), source:'manual' }; 11862 rec.date=v.date||tod(); rec.property_id=v.property_id?parseInt(v.property_id):null; rec.unit_id=v.unit_id?parseInt(v.unit_id):null; 11863 rec.category=v.category; rec.direction=invDirFor(v.category); rec.amount=invNum(v.amount); rec.payee=v.payee.trim(); rec.notes=v.notes.trim();
11864 // HOA entries link to the property's HOA account when available 11865 if(v.category==='HOA' && rec.property_id){ var pp=invProp(rec.property_id); rec.hoa_id = pp?pp.hoa_id:null; } else if(!entry){ rec.hoa_id=null; } 11866 if(!entry) ILED.push(rec); saveInvLedger(rec); renderInvestments(); 11867 }); 11868} 11869 11870// Per-property income/expense/net for a month. 11871function propMonthIO(pid, ym){ 11872 var inc=0, exp=0; 11873 ILED.forEach(function(l){ if(String(l.property_id)!==String(pid) || !ledInMonth(l,ym)) return; if((l.direction||invDirFor(l.category))==='income') inc+=invNum(l.amount); else exp+=invNum(l.amount); }); 11874 return { inc:inc, exp:exp, net:inc-exp }; 11875} 11876function invYtd(){ 11877 var yr=String(new Date().getFullYear()), inc=0, exp=0; 11878 ILED.forEach(function(l){ if((l.date||'').slice(0,4)!==yr) return; if((l.direction||invDirFor(l.category))==='income') inc+=invNum(l.amount); else exp+=invNum(l.amount); }); 11879 return inc-exp; 11880} 11881// Ledger rows come ONLY from real emails/PDF statements or manual entry - never synthesized. The old 11882// build auto-posted monthly mortgage rows (source='recurring'); this removes any that already exist so 11883// "actual" numbers reflect real money movement only. Idempotent: after the first pass there are none. 11884function purgeSyntheticLedger(){ 11885 var synthetic=ILED.filter(function(l){ return l.source==='recurring'; }); 11886 if(!synthetic.length) return false; 11887 var ids={}; synthetic.forEach(function(l){ ids[l.id]=true; }); 11888 ILED=ILED.filter(function(l){ return !ids[l.id]; }); 11889 sv(); 11890 if(supaReady) synthetic.forEach(function(l){ dbDeleteBy('inv_ledger','id',l.id); }); 11891 return true; 11892} 11893// ---- Recurring monthly expenses/income ------------------------------------------------------------ 11894// Templates live in INVREC. Each month, FIXED items (variable=false, e.g. a mortgage) auto-post a real 11895// inv_ledger row; VARIABLE items (variable=true, e.g. utilities) post NOTHING until you confirm the 11896// actual amount â so ledger totals only ever hold real figures. Rows carry source 'recur:<id>', which 11897// is distinct from the purged legacy 'recurring' source, so they are never auto-deleted. 11898function recurMoney(n){ n=invNum(n); return '$'+n.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2}); } 11899function recurYM(){ var d=new Date(); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0'); } 11900function recurDateFor(ym, dayOfMonth){ var p=String(ym).split('-'); var y=+p[0], m=+p[1]; var last=new Date(y,m,0).getDate(); var day=Math.min(Math.max(parseInt(dayOfMonth)||1,1), last); return ym+'-'+String(day).padStart(2,'0'); } 11901function recurRowExists(tid, ym){ return ILED.some(function(l){ return l.source==='recur:'+tid && String(l.date||'').slice(0,7)===ym; }); } 11902function recurMakeRow(t, ym, amount){ 11903 var rec={ id:Date.now()+Math.floor(Math.random()*100000), date:recurDateFor(ym, t.day_of_month), 11904 property_id:t.property_id?parseInt(t.property_id):null, unit_id:t.unit_id?parseInt(t.unit_id):null, 11905 category:t.category, direction:invDirFor(t.category), amount:invNum(amount), payee:t.payee||'', 11906 source:'recur:'+t.id, notes:t.notes||'' }; 11907 if(t.category==='HOA' && rec.property_id){ var pp=invProp(rec.property_id); rec.hoa_id=pp?pp.hoa_id:null; } 11908 return rec; 11909} 11910function materializeRecurring(){ 11911 if(typeof INVREC==='undefined' || !INVREC || !INVREC.length) return; 11912 var ym=recurYM(); var made=[]; 11913 INVREC.forEach(function(t){ 11914 if(!t.active || t.variable) return; // variable ones wait for confirmation 11915 if(recurRowExists(t.id, ym)) return; // already posted this month 11916 var rec=recurMakeRow(t, ym, t.amount); ILED.push(rec); made.push(rec); 11917 }); 11918 if(made.length){ sv(); if(supaReady) dbSave('inv_ledger', made); } 11919} 11920function recurPendingThisMonth(){ var ym=recurYM(); return (INVREC||[]).filter(function(t){ return t.active && t.variable && !recurRowExists(t.id, ym); }); } 11921function recurConfirmVariable(t){ 11922 var ym=recurYM(); 11923 invOpenForm('Confirm '+(t.category||'bill')+' â this month', [ 11924 {key:'amount',label:'Actual amount from this monthâs bill',type:'number',required:true} 11925 ], { amount: t.amount||'' }, function(v){ 11926 if(invNum(v.amount)<=0){ alert('Enter the amount from this monthâs bill.'); return false; } 11927 if(recurRowExists(t.id, ym)) return; 11928 var rec=recurMakeRow(t, ym, v.amount); ILED.push(rec); saveInvLedger(rec); renderInvestments(); 11929 }); 11930} 11931function recurLabel(t){ var p=t.property_id?invProp(t.property_id):null; var who=p?(p.name||p.address||'Property'):'â'; return (t.category||'')+' · '+who; } 11932function openRecurringForm(t){ 11933 invOpenForm(t?'Edit recurring item':'Add recurring item', [ 11934 {key:'property_id',label:'Property',type:'select',options:invPropOptions()}, 11935 {key:'unit_id',label:'Unit (optional, for rent)',type:'select',options:unitOptionsFor(t?t.property_id:'')}, 11936 {key:'category',label:'Category',type:'select',options:INV_INCOME_CATS.concat(INV_EXPENSE_CATS).map(function(c){return {value:c,label:c};})}, 11937 {key:'payee',label:'Payee / payer'}, 11938 {key:'amount',label:'Amount (usual / fixed)',type:'number',required:true}, 11939 {key:'day_of_month',label:'Day of month (1-31)',type:'number',required:true}, 11940 {key:'variable',label:'Amount changes monthly?',type:'select',options:[{value:'no',label:'No â fixed, post automatically'},{value:'yes',label:'Yes â I confirm the amount each month'}]}, 11941 {key:'active',label:'Active',type:'select',options:[{value:'yes',label:'Yes'},{value:'no',label:'No (paused)'}]} 11942 ], t ? { property_id:t.property_id||'', unit_id:t.unit_id||'', category:t.category||'Mortgage', payee:t.payee||'', amount:t.amount||'', day_of_month:t.day_of_month||1, variable:t.variable?'yes':'no', active:(t.active===false?'no':'yes') } 11943 : { category:'Mortgage', variable:'no', active:'yes', day_of_month:1 }, 11944 function(v){ 11945 if(invNum(v.amount)<=0){ alert('Enter an amount greater than 0.'); return false; } 11946 var rec = t || { id:Date.now()+Math.floor(Math.random()*100000) }; 11947 rec.property_id = v.property_id?parseInt(v.property_id):null; 11948 rec.unit_id = v.unit_id?parseInt(v.unit_id):null; 11949 rec.category = v.category; rec.payee=(v.payee||'').trim(); rec.amount=invNum(v.amount);
11950 rec.day_of_month = Math.min(Math.max(parseInt(v.day_of_month)||1,1),31); 11951 rec.variable = (v.variable==='yes'); rec.active = (v.active!=='no'); rec.notes = rec.notes||''; 11952 if(!t) INVREC.push(rec); saveInvRecurring(rec); 11953 try{ materializeRecurring(); }catch(e){} 11954 renderInvestments(); 11955 }); 11956} 11957function openRecurringModal(){ 11958 var ov=document.createElement('div'); ov.className='modal-ov open'; ov.style.zIndex='1300'; 11959 var m=document.createElement('div'); m.className='modal'; m.style.maxWidth='640px'; 11960 var h=document.createElement('div'); h.style.cssText='display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border);'; 11961 h.innerHTML='<div style="font-weight:700;font-size:17px;">Recurring monthly items</div>'; 11962 var x=document.createElement('button'); x.textContent='â'; x.style.cssText='background:none;border:none;font-size:18px;color:var(--text3);cursor:pointer;'; x.addEventListener('click',function(){ document.body.removeChild(ov); }); h.appendChild(x); m.appendChild(h); 11963 var body=document.createElement('div'); body.style.cssText='padding:16px 20px;display:flex;flex-direction:column;gap:10px;max-height:70vh;overflow:auto;'; 11964 body.appendChild(mkDivSafe('font-size:12px;color:var(--text3);line-height:1.5;','Fixed items (like a mortgage) post automatically each month. Variable items (like utilities) wait for you to confirm the real amount before they count toward your totals.')); 11965 if(!INVREC.length){ body.appendChild(mkDivSafe('color:var(--text3);padding:10px 0;','No recurring items yet. Add one below.')); } 11966 INVREC.slice().sort(function(a,b){ return String(recurLabel(a)).localeCompare(String(recurLabel(b))); }).forEach(function(t){ 11967 var row=document.createElement('div'); row.style.cssText='display:flex;justify-content:space-between;align-items:center;gap:10px;border:1px solid var(--border);border-radius:8px;padding:10px 12px;'+(t.active===false?'opacity:.6;':''); 11968 var tag=t.variable?'<span style="font-size:10px;font-weight:700;color:#b26a00;border:1px solid #e3a93c;border-radius:4px;padding:1px 5px;margin-left:6px;">VARIABLE</span>':'<span style="font-size:10px;font-weight:700;color:#1f9d55;border:1px solid #1f9d55;border-radius:4px;padding:1px 5px;margin-left:6px;">FIXED</span>'; 11969 row.innerHTML='<div><div style="font-weight:600;">'+_esc(recurLabel(t))+tag+(t.active===false?' <span style="font-size:10px;color:var(--text3);">(paused)</span>':'')+'</div><div style="font-size:12px;color:var(--text3);">'+_esc((t.payee?(t.payee+' · '):'')+recurMoney(t.amount)+' · day '+(t.day_of_month||1))+'</div></div>'; 11970 var btns=document.createElement('div'); btns.style.cssText='display:flex;gap:6px;flex-shrink:0;'; 11971 var ed=document.createElement('button'); ed.className='btn btn-g'; ed.style.cssText='font-size:12px;padding:3px 9px;'; ed.textContent='Edit'; ed.addEventListener('click',function(){ document.body.removeChild(ov); openRecurringForm(t); }); 11972 var del=document.createElement('button'); del.className='btn btn-g'; del.style.cssText='font-size:12px;padding:3px 9px;'; del.textContent='Delete'; (function(id){ del.addEventListener('click',function(){ if(confirm('Delete this recurring item? Past ledger entries stay.')){ delInvRecurring(id); document.body.removeChild(ov); openRecurringModal(); } }); })(t.id); 11973 btns.appendChild(ed); btns.appendChild(del); row.appendChild(btns); body.appendChild(row); 11974 }); 11975 m.appendChild(body); 11976 var ft=document.createElement('div'); ft.style.cssText='display:flex;justify-content:space-between;gap:8px;padding:14px 20px;border-top:1px solid var(--border);'; 11977 var add=document.createElement('button'); add.className='btn btn-p'; add.textContent='+ Add recurring item'; add.addEventListener('click',function(){ document.body.removeChild(ov); openRecurringForm(null); }); 11978 var cl=document.createElement('button'); cl.className='btn btn-g'; cl.textContent='Close'; cl.addEventListener('click',function(){ document.body.removeChild(ov); }); 11979 ft.appendChild(add); ft.appendChild(cl); m.appendChild(ft); 11980 ov.appendChild(m); ov.addEventListener('click',function(e){ if(e.target===ov) document.body.removeChild(ov); }); 11981 document.body.appendChild(ov); 11982} 11983var _invLedgerFilter=''; // property_id filter for the ledger table ('' = all) 11984var _invDetailFrom='', _invDetailTo='', _invDetailPid=null; // date-range filter inside a property detail view ('' = open end) 11985// Dark "money page" palette (this page is intentionally dark to stand apart from the rest of the CRM). 11986var IVC={ bg:'#0e1118', card:'#161b24', tile:'#1b2230', bord:'#29313f', txt:'#e7eaf0', mut:'#98a2b3', 11987 grn:'#46c05f', red:'#f0776c', warn:'#e3a93c', warnbg:'rgba(227,169,60,0.12)', grnbg:'rgba(70,192,95,0.13)', redbg:'rgba(240,119,108,0.12)', accent:'#3a4a63' }; 11988function _ivStyle(){ 11989 if(ge('invDarkCss')) return; 11990 var s=document.createElement('style'); s.id='invDarkCss'; 11991 s.textContent='#page-investments,#page-hardmoney{background:'+IVC.bg+';}' 11992 + '#invRoot,#hmRoot{background:'+IVC.bg+';color:'+IVC.txt+';padding:16px;border-radius:12px;min-height:70vh;}' 11993 + '#invRoot .ivbtn,#hmRoot .ivbtn{background:transparent;border:1px solid '+IVC.bord+';color:'+IVC.txt+';border-radius:7px;padding:6px 12px;font-size:14px;font-family:inherit;cursor:pointer;white-space:nowrap;}' 11994 + '#invRoot .ivbtn:hover,#hmRoot .ivbtn:hover{background:'+IVC.tile+';}' 11995 + '#invRoot .ivbtn.pri,#hmRoot .ivbtn.pri{background:'+IVC.accent+';border-color:'+IVC.accent+';}' 11996 + '#invRoot .ivbtn.sm,#hmRoot .ivbtn.sm{padding:3px 9px;font-size:12px;}' 11997 + '#invRoot .ivbtn.danger:hover,#hmRoot .ivbtn.danger:hover{border-color:'+IVC.red+';color:'+IVC.red+';}' 11998 + '#invRoot input,#invRoot select,#hmRoot input,#hmRoot select{background:'+IVC.tile+';border:1px solid '+IVC.bord+';color:'+IVC.txt+';border-radius:7px;padding:5px 8px;font-family:inherit;font-size:14px;}' 11999 + '#invRoot table,#hmRoot table{width:100%;border-collapse:collapse;font-size:14px;}' 12000 + '#invRoot th,#hmRoot th{color:'+IVC.mut+';font-weight:500;text-align:left;padding:7px 8px;font-size:13px;}' 12001 + '#invRoot td,#hmRoot td{padding:7px 8px;border-top:1px solid '+IVC.bord+';}' 12002 + '#invRoot tbody tr:nth-child(even),#hmRoot tbody tr:nth-child(even){background:'+IVC.tile+';}' 12003 + '#invRoot tbody tr:hover,#hmRoot tbody tr:hover{background:#212a38;}'; 12004 document.head.appendChild(s); 12005} 12006function _relTime(iso){ 12007 if(!iso) return ''; var t=new Date(iso).getTime(); if(isNaN(t)) return ''; 12008 var s=Math.floor((Date.now()-t)/1000); if(s<60) return 'just now'; 12009 var m=Math.floor(s/60); if(m<60) return m+'m ago'; 12010 var h=Math.floor(m/60); if(h<24) return h+'h ago'; 12011 return Math.floor(h/24)+'d ago'; 12012} 12013function lastScan(){ return (ISCAN&&ISCAN.length) ? ISCAN.slice().sort(function(a,b){return String(b.ran_at||'').localeCompare(String(a.ran_at||''));})[0] : null; } 12014function openScanLog(){ 12015 var ov=document.createElement('div'); ov.className='modal-ov open'; ov.style.zIndex='1290'; 12016 var m=document.createElement('div'); m.className='modal'; m.style.maxWidth='560px'; 12017 m.innerHTML='<div style="padding:16px 20px;border-bottom:1px solid var(--border);font-weight:700;font-size:17px;">Scan history</div>'; 12018 var body=document.createElement('div'); body.style.cssText='padding:8px 20px;max-height:60vh;overflow:auto;'; m.appendChild(body); 12019 var rows=ISCAN.slice().sort(function(a,b){return String(b.ran_at||'').localeCompare(String(a.ran_at||''));}); 12020 if(!rows.length) body.appendChild(mkDivSafe('color:var(--text3);padding:12px 0;','No scans recorded yet. The cron writes a row each run (~3Ã/day); the button does too.')); 12021 rows.forEach(function(s){ 12022 var r=document.createElement('div'); r.style.cssText='padding:8px 0;border-bottom:1px solid var(--border);';
12023 var when=s.ran_at?(new Date(s.ran_at).toLocaleString()):''; 12024 var meta=(s.trigger==='auto'?'auto (cron)':'manual')+(s.ok===false?' · error':''); 12025 var stats=(s.added||0)+' added · '+(s.skipped||0)+' recorded · '+(s.unmatched||0)+' ignored'+(s.no_pdf?(' · '+s.no_pdf+' no-PDF'):''); 12026 r.innerHTML='<div style="font-size:13px;">'+_esc(when)+' <span style="color:var(--text3);">('+_esc(meta)+')</span></div><div style="font-size:12px;color:var(--text3);">'+_esc(stats)+(s.note?(' · '+_esc(s.note)):'')+'</div>'; 12027 body.appendChild(r); 12028 }); 12029 var ft=document.createElement('div'); ft.style.cssText='display:flex;justify-content:flex-end;padding:14px 20px;border-top:1px solid var(--border);'; 12030 var c=document.createElement('button'); c.className='btn btn-g'; c.textContent='Close'; c.addEventListener('click',function(){document.body.removeChild(ov);}); ft.appendChild(c); m.appendChild(ft); 12031 ov.appendChild(m); ov.addEventListener('click',function(e){if(e.target===ov)document.body.removeChild(ov);}); 12032 document.body.appendChild(ov); 12033} 12034function renderInvestments(){ 12035 _ivStyle(); 12036 try{ purgeSyntheticLedger(); }catch(e){} // ledger holds only real rows - drop any synthesized ones 12037 try{ materializeRecurring(); }catch(e){} // post this month's FIXED recurring items (real amounts) 12038 var root=ge('invRoot'); if(!root) return; root.innerHTML=''; 12039 var ym=invCurMonth(); 12040 12041 // ---- Toolbar: month nav + primary actions ---- 12042 var bar=document.createElement('div'); bar.style.cssText='display:flex;flex-wrap:wrap;gap:10px;align-items:center;justify-content:space-between;margin-bottom:14px;'; 12043 var mnav=document.createElement('div'); mnav.style.cssText='display:flex;align-items:center;gap:8px;'; 12044 var prev=document.createElement('button'); prev.className='ivbtn'; prev.innerHTML='â¹'; prev.style.cssText='padding:5px 11px;'; prev.addEventListener('click',function(){invShiftMonth(-1);}); 12045 var mlab=document.createElement('div'); mlab.style.cssText='font-weight:500;font-size:17px;min-width:150px;text-align:center;'; mlab.textContent=invMonthLabel(ym); 12046 var next=document.createElement('button'); next.className='ivbtn'; next.innerHTML='âº'; next.style.cssText='padding:5px 11px;'; next.addEventListener('click',function(){invShiftMonth(1);}); 12047 mnav.appendChild(prev); mnav.appendChild(mlab); mnav.appendChild(next); 12048 var acts=document.createElement('div'); acts.style.cssText='display:flex;gap:8px;flex-wrap:wrap;'; 12049 function ivb(label,cls,fn){ var b=document.createElement('button'); b.className='ivbtn'+(cls?(' '+cls):''); b.textContent=label; b.addEventListener('click',fn); return b; } 12050 acts.appendChild(ivb('+ Log entry','pri',function(){ openInvLedgerForm(); })); 12051 var sinceVal; try{ sinceVal = localStorage.getItem('invLastScan') || (new Date().getFullYear()+'-01-01'); }catch(e){ sinceVal = new Date().getFullYear()+'-01-01'; } 12052 acts.appendChild(ivb('Scan email','',function(){ scanFinanceEmails(sinceVal); })); 12053 acts.appendChild(ivb('Payees','',function(){ openInvPayeesModal(); })); 12054 acts.appendChild(ivb('Recurring','',function(){ openRecurringModal(); })); 12055 acts.appendChild(ivb('Export','',function(){ invExportModal('properties'); })); 12056 bar.appendChild(mnav); bar.appendChild(acts); 12057 root.appendChild(bar); 12058 (function(){ var pend=recurPendingThisMonth(); if(!pend.length) return; 12059 var pc=document.createElement('div'); pc.style.cssText='background:'+IVC.warnbg+';border:1px solid '+IVC.warn+';border-radius:10px;padding:12px 14px;margin-bottom:14px;'; 12060 pc.appendChild(mkDivSafe('font-weight:600;color:'+IVC.warn+';margin-bottom:8px;','Confirm this monthâs recurring bills')); 12061 pend.forEach(function(t){ var r=document.createElement('div'); r.style.cssText='display:flex;justify-content:space-between;align-items:center;gap:10px;padding:5px 0;'; 12062 r.appendChild(mkDivSafe('font-size:13px;color:'+IVC.txt+';',_esc(recurLabel(t)+(t.payee?(' · '+t.payee):''))+' â usually '+recurMoney(t.amount))); 12063 var b=ivb('Enter amount','pri',function(){ recurConfirmVariable(t); }); b.style.padding='4px 10px'; b.style.fontSize='13px'; r.appendChild(b); pc.appendChild(r); 12064 }); 12065 root.appendChild(pc); 12066 })(); 12067 12068 // Scan heartbeat: proves the cron is alive. Warns (amber) if the last run is stale (>14h). 12069 (function(){ 12070 var ls=lastScan(); 12071 var stale = !ls || (Date.now()-new Date(ls.ran_at).getTime()) > 14*3600*1000; 12072 var txt = ls ? ('Last scan '+_relTime(ls.ran_at)+' · '+(ls.trigger==='auto'?'auto':'manual')+' · '+(ls.added||0)+' added'+((ls.ok===false)?' · error':'')) : 'No scans yet'; 12073 var wrap=document.createElement('div'); wrap.style.cssText='margin:-4px 0 12px;font-size:12px;'; 12074 var a=document.createElement('span'); a.style.cssText='color:'+(stale?IVC.warn:IVC.mut)+';cursor:pointer;'; a.textContent=(stale?'â ':'')+txt+' · history'; 12075 a.addEventListener('click', openScanLog); wrap.appendChild(a); root.appendChild(wrap); 12076 })(); 12077 12078 if(!IPROP.length){ 12079 var empty=document.createElement('div'); empty.style.cssText='padding:44px;text-align:center;color:'+IVC.mut+';'; 12080 empty.innerHTML='No properties yet. Add your first one to get started.'; 12081 var addP=ivb('+ Property','pri',function(){ openInvPropertyForm(); }); addP.style.marginTop='14px'; 12082 empty.appendChild(document.createElement('br')); empty.appendChild(addP); 12083 root.appendChild(empty); updateInvBadge(); return; 12084 } 12085 12086 // ---- Summary tiles ---- 12087 var act=invActual(ym); var net=act.income-act.expenses; var ytd=invYtd(); 12088 var grid=document.createElement('div'); grid.style.cssText='display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:16px;'; 12089 function tile(lbl,val,color){ var d=document.createElement('div'); d.style.cssText='background:'+IVC.tile+';border-radius:10px;padding:14px 16px;'; 12090 d.innerHTML='<div style="font-size:13px;color:'+IVC.mut+';">'+lbl+'</div><div style="font-size:24px;font-weight:500;margin-top:3px;color:'+(color||IVC.txt)+';">'+val+'</div>'; return d; } 12091 grid.appendChild(tile('Income', invMoney(act.income), IVC.grn)); 12092 grid.appendChild(tile('Expenses', invMoney(act.expenses), IVC.red)); 12093 grid.appendChild(tile('Net this month', invMoney(net), net>=0?IVC.txt:IVC.red)); 12094 grid.appendChild(tile('Net year to date', invMoney(ytd), ytd>=0?IVC.txt:IVC.red)); 12095 root.appendChild(grid); 12096 12097 // ---- Attention banner ---- 12098 var flags=invFlags(ym); 12099 if(flags.length){ 12100 var fb=document.createElement('div'); fb.style.cssText='display:flex;gap:10px;align-items:flex-start;background:'+IVC.warnbg+';border:1px solid rgba(227,169,60,0.35);border-radius:10px;padding:10px 14px;margin-bottom:16px;'; 12101 fb.innerHTML='<span style="color:'+IVC.warn+';font-size:18px;line-height:1;">▲</span>'; 12102 var ftxt=document.createElement('div'); ftxt.style.cssText='font-size:14px;color:'+IVC.warn+';'; 12103 ftxt.innerHTML='<b>'+flags.length+(flags.length===1?' item needs':' items need')+' attention</b><br>'+flags.map(function(f){return _esc(f.text);}).join('<br>'); 12104 fb.appendChild(ftxt); root.appendChild(fb); 12105 } 12106 12107 // ---- Property cards ---- 12108 root.appendChild(mkDivSafe('font-size:13px;color:'+IVC.mut+';margin:2px 0 8px;','Properties')); 12109 var pgrid=document.createElement('div'); pgrid.style.cssText='display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px;margin-bottom:20px;'; 12110 IPROP.forEach(function(p){ 12111 var io=propMonthIO(p.id, ym); 12112 var pc=document.createElement('div'); pc.style.cssText='background:'+IVC.card+';border:1px solid '+IVC.bord+';border-radius:12px;padding:14px 16px;'; 12113 // header + status pill 12114 var hd=document.createElement('div'); hd.style.cssText='display:flex;justify-content:space-between;align-items:flex-start;gap:8px;'; 12115 hd.innerHTML='<div><div style="font-weight:500;font-size:16px;">'+_esc(p.name||p.address||'Property')+'</div>'+(p.address?('<div style="font-size:13px;color:'+IVC.mut+';">'+_esc(p.address)+'</div>'):'')+'</div>'; 12116 var units=unitsForProp(p.id); 12117 var anyVacant=units.some(function(u){return (u.status||'occupied')==='vacant';}); 12118 var rentDue=propExpectedRent(p.id)>0 && !ILED.some(function(l){ return ledInMonth(l,ym) && l.category==='Rent' && String(l.property_id)===String(p.id); }); 12119 var st = rentDue ? {t:'Rent due',c:IVC.warn,b:IVC.warnbg} : anyVacant ? {t:'Vacant',c:IVC.red,b:IVC.redbg} : {t:'Occupied',c:IVC.grn,b:IVC.grnbg}; 12120 var pill=document.createElement('span'); pill.style.cssText='font-size:12px;color:'+st.c+';background:'+st.b+';padding:2px 9px;border-radius:7px;white-space:nowrap;flex-shrink:0;'; pill.textContent=st.t; 12121 hd.appendChild(pill); pc.appendChild(hd); 12122 // In / Out / Net 12123 var ion=document.createElement('div'); ion.style.cssText='display:flex;gap:18px;margin:12px 0 10px;'; 12124 function stat(lbl,val,color){ return '<div><div style="font-size:12px;color:'+IVC.mut+';">'+lbl+'</div><div style="font-weight:500;color:'+color+';">'+val+'</div></div>'; } 12125 ion.innerHTML=stat('In',invMoney(io.inc),IVC.grn)+stat('Out',invMoney(io.exp),IVC.red)+stat('Net',invMoney(io.net),IVC.txt); 12126 pc.appendChild(ion); 12127 // Rent: expected (from the unit's editable terms) vs what actually landed, with the shortfall 12128 // called out as a repair. This is the manager-controlled rent side - clear at a glance. 12129 var _expDep=propExpectedDeposit(p.id); 12130 if(_expDep>0){ 12131 var _got=propRentReceived(p.id, ym); var _short=propRentShortfall(p.id, ym); 12132 var rr=document.createElement('div'); rr.style.cssText='font-size:13px;margin:2px 0 10px;color:'+IVC.mut+';line-height:1.5;'; 12133 var _line='Rent expected <b style="color:'+IVC.txt+';">'+invMoney(_expDep)+'</b>'; 12134 if(_got>0){ 12135 _line += ' · got <b style="color:'+(_short>0?IVC.warn:IVC.grn)+';">'+invMoney(_got)+'</b>'; 12136 _line += _short>0 ? (' · <span style="color:'+IVC.red+';">'+invMoney(_short)+' short (repair)</span>') 12137 : (' · <span style="color:'+IVC.grn+';">on target</span>'); 12138 } else { 12139 _line += ' · <span style="color:'+IVC.warn+';">not received yet</span>'; 12140 } 12141 rr.innerHTML=_line; pc.appendChild(rr); 12142 } 12143 // Header + summary open the property detail view. 12144 hd.style.cursor='pointer'; ion.style.cursor='pointer'; 12145 (function(pid){ hd.addEventListener('click',function(){ openInvPropertyDetail(pid); }); ion.addEventListener('click',function(){ openInvPropertyDetail(pid); }); })(p.id); 12146 // footer meta 12147 var meta=[]; if(propExpectedRent(p.id)>0) meta.push('Rent '+invMoney(propExpectedRent(p.id))); 12148 var hoa=p.hoa_id?invHoa(p.hoa_id):null; if(hoa) meta.push('HOA '+_esc(hoa.name||'')+' '+invMoney(hoa.dues_amount)); 12149 if(invNum(p.mortgage_payment)>0) meta.push('Mortgage '+invMoney(p.mortgage_payment)); 12150 var occTenant=units.filter(function(u){return u.tenant_name;}).map(function(u){return _esc(u.tenant_name);}); 12151 if(occTenant.length) meta.push('Tenant '+occTenant.join(', ')); 12152 var mf=document.createElement('div'); mf.style.cssText='font-size:13px;color:'+IVC.mut+';border-top:1px solid '+IVC.bord+';padding-top:8px;'; mf.innerHTML=meta.join(' · ')||'No rent/HOA set'; 12153 pc.appendChild(mf); 12154 // per-unit lines (only when >1 unit) - click to edit 12155 if(units.length>1){
12156 units.forEach(function(u){ 12157 var ul=document.createElement('div'); ul.style.cssText='font-size:13px;color:'+IVC.mut+';margin-top:4px;cursor:pointer;'; 12158 ul.innerHTML='• '+_esc(u.label)+' â '+invMoney(u.rent_amount)+(u.status==='vacant'?' <span style="color:'+IVC.red+';">(vacant)</span>':(u.tenant_name?(' · '+_esc(u.tenant_name)):'')); 12159 (function(u2){ ul.addEventListener('click',function(){ openInvUnitForm(u2.property_id,u2); }); })(u); 12160 pc.appendChild(ul); 12161 }); 12162 } 12163 // actions 12164 var pa=document.createElement('div'); pa.style.cssText='display:flex;gap:6px;flex-wrap:wrap;margin-top:12px;'; 12165 pa.appendChild(ivb('Log','sm',(function(p2){return function(){ openInvLedgerForm(null,{property_id:String(p2.id)}); };})(p))); 12166 pa.appendChild(ivb('+ Unit','sm',(function(p2){return function(){ openInvUnitForm(p2.id); };})(p))); 12167 pa.appendChild(ivb('Edit','sm',(function(p2){return function(){ openInvPropertyForm(p2); };})(p))); 12168 var pdel=ivb('Delete','sm danger',(function(p2){return function(){ if(confirm('Delete "'+(p2.name||p2.address)+'" and all its units + ledger entries?')){ delInvProp(p2.id); renderInvestments(); } };})(p)); 12169 pa.appendChild(pdel); 12170 pc.appendChild(pa); 12171 pgrid.appendChild(pc); 12172 }); 12173 root.appendChild(pgrid); 12174 12175 // ---- Ledger table ---- 12176 var lhd=document.createElement('div'); lhd.style.cssText='display:flex;align-items:center;justify-content:space-between;gap:8px;margin:0 0 8px;'; 12177 lhd.appendChild(mkDivSafe('font-size:13px;color:'+IVC.mut+';','Ledger â '+invMonthLabel(ym))); 12178 var fsel=document.createElement('select'); fsel.style.cssText='width:auto;'; 12179 var oAll=document.createElement('option'); oAll.value=''; oAll.textContent='All properties'; fsel.appendChild(oAll); 12180 IPROP.forEach(function(p){ var o=document.createElement('option'); o.value=String(p.id); o.textContent=(p.name||p.address); if(String(p.id)===String(_invLedgerFilter)) o.selected=true; fsel.appendChild(o); }); 12181 fsel.addEventListener('change',function(){ _invLedgerFilter=fsel.value; renderInvestments(); }); 12182 lhd.appendChild(fsel); root.appendChild(lhd); 12183 12184 var rows=ILED.filter(function(l){ return ledInMonth(l,ym) && (!_invLedgerFilter || String(l.property_id)===String(_invLedgerFilter)); }) 12185 .sort(function(a,b){ return (b.date||'').localeCompare(a.date||''); }); 12186 var tbl=document.createElement('table'); 12187 tbl.innerHTML='<thead><tr><th style="width:62px;">Date</th><th>Property</th><th>Category</th><th style="text-align:right;width:96px;">Amount</th><th style="width:26px;"></th></tr></thead>'; 12188 var tb=document.createElement('tbody'); 12189 if(!rows.length){ var tr0=document.createElement('tr'); tr0.innerHTML='<td colspan="5" style="color:'+IVC.mut+';">No entries this month.</td>'; tb.appendChild(tr0); } 12190 rows.forEach(function(l){ 12191 var pnm=l.property_id?((invProp(l.property_id)||{}).name||(invProp(l.property_id)||{}).address||''):'â'; 12192 var dir=(l.direction||invDirFor(l.category)); var amtColor=dir==='income'?IVC.grn:IVC.red; var sign=dir==='income'?'+':'â'; 12193 var tr=document.createElement('tr'); tr.style.cursor='pointer'; 12194 var tag=(l.source==='email'||l.source==='auto')?' <span style="font-size:11px;color:'+IVC.mut+';">('+_esc(l.source)+')</span>':''; 12195 var due=l.due_date?(' <span style="font-size:11px;color:'+IVC.warn+';background:'+IVC.warnbg+';padding:1px 6px;border-radius:6px;white-space:nowrap;">Due '+_esc(fd(l.due_date))+'</span>'):''; 12196 tr.innerHTML='<td>'+_esc(fd(l.date))+'</td><td>'+_esc(pnm)+'</td><td>'+_esc(l.category)+tag+due+'</td>' 12197 + '<td style="text-align:right;color:'+amtColor+';">'+sign+invMoney(l.amount).replace('-','')+'</td>'; 12198 var tdx=document.createElement('td'); tdx.style.textAlign='center'; 12199 var xb=document.createElement('span'); xb.textContent='Ã'; xb.title='Delete'; xb.style.cssText='color:'+IVC.mut+';cursor:pointer;font-size:16px;'; 12200 (function(l2){ xb.addEventListener('click',function(e){ e.stopPropagation(); if(confirm('Delete this entry?')){ delInvLedger(l2.id); renderInvestments(); } }); })(l); 12201 tdx.appendChild(xb); tr.appendChild(tdx); 12202 (function(l2){ tr.addEventListener('click',function(){ openInvLedgerForm(l2); }); })(l); 12203 tb.appendChild(tr); 12204 }); 12205 tbl.appendChild(tb); root.appendChild(tbl); 12206 12207 // ---- Manage row (secondary) ---- 12208 var mrow=document.createElement('div'); mrow.style.cssText='display:flex;gap:8px;flex-wrap:wrap;margin-top:18px;padding-top:12px;border-top:1px solid '+IVC.bord+';'; 12209 mrow.appendChild(ivb('+ Property','sm',function(){ openInvPropertyForm(); })); 12210 mrow.appendChild(ivb('+ HOA account','sm',function(){ openInvHoaForm(); })); 12211 mrow.appendChild(ivb('Import CSV','sm',function(){ invImportCsv(); })); 12212 // HOA quick-manage (edit dues / log) 12213 IHOA.forEach(function(h){ mrow.appendChild(ivb('HOA: '+((h.name||'account').slice(0,18)),'sm',(function(h2){return function(){ openInvHoaForm(h2); };})(h))); }); 12214 root.appendChild(mrow); 12215 12216 updateInvBadge(); 12217} 12218function mkDivSafe(style, html){ var d=document.createElement('div'); if(style) d.style.cssText=style; if(html) d.innerHTML=html; return d; } 12219 12220// Property detail view (click a property card). Shows facts, units, category totals, and the 12221// full ledger history for that one property. 12222function openInvPropertyDetail(pid){ 12223 _ivStyle(); 12224 try{ purgeSyntheticLedger(); }catch(e){} 12225 var root=ge('invRoot'); if(!root) return; root.innerHTML='';
12226 var p=invProp(pid); if(!p){ renderInvestments(); return; } 12227 if(_invDetailPid!==String(pid)){ _invDetailFrom=''; _invDetailTo=''; _invDetailPid=String(pid); } // reset range when switching property 12228 function ivb(label,cls,fn){ var b=document.createElement('button'); b.className='ivbtn'+(cls?(' '+cls):''); b.textContent=label; b.addEventListener('click',fn); return b; } 12229 12230 // Back + title row 12231 var top=document.createElement('div'); top.style.cssText='display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:14px;'; 12232 var back=ivb('â¹ Back','',function(){ renderInvestments(); }); 12233 var tacts=document.createElement('div'); tacts.style.cssText='display:flex;gap:6px;flex-wrap:wrap;'; 12234 tacts.appendChild(ivb('Log entry','pri sm',function(){ openInvLedgerForm(null,{property_id:String(p.id)}); openInvPropertyDetail(pid); })); 12235 tacts.appendChild(ivb('+ Unit','sm',function(){ openInvUnitForm(p.id); })); 12236 tacts.appendChild(ivb('Edit','sm',function(){ openInvPropertyForm(p); })); 12237 top.appendChild(back); top.appendChild(tacts); root.appendChild(top); 12238 12239 root.appendChild(mkDivSafe('font-size:22px;font-weight:500;', _esc(p.name||p.address||'Property'))); 12240 if(p.address) root.appendChild(mkDivSafe('font-size:14px;color:'+IVC.mut+';margin-bottom:14px;', _esc(p.address))); 12241 12242 // Facts grid 12243 var facts=[]; 12244 var hoa=p.hoa_id?invHoa(p.hoa_id):null; 12245 if(hoa) facts.push(['HOA', _esc(hoa.name||'')+' â '+invMoney(hoa.dues_amount)+'/'+(hoa.dues_frequency||'mo')+(hoa.account_number?(' · #'+_esc(hoa.account_number)):'')]); 12246 if(invNum(p.mortgage_payment)>0 || p.mortgage_lender) facts.push(['Mortgage', (p.mortgage_lender?_esc(p.mortgage_lender)+' â ':'')+(invNum(p.mortgage_payment)>0?invMoney(p.mortgage_payment)+'/mo':'')+(invNum(p.mortgage_balance)>0?(' · balance '+invMoney(p.mortgage_balance)):'')]); 12247 if(invNum(p.purchase_price)>0) facts.push(['Purchase', invMoney(p.purchase_price)+(p.purchase_date?(' · '+_esc(p.purchase_date)):'')]); 12248 if(p.notes) facts.push(['Notes / manager / warranty', _esc(p.notes)]); 12249 if(facts.length){ 12250 var fg=document.createElement('div'); fg.style.cssText='background:'+IVC.card+';border:1px solid '+IVC.bord+';border-radius:12px;padding:12px 16px;margin-bottom:16px;'; 12251 facts.forEach(function(f,i){ var r=document.createElement('div'); r.style.cssText='padding:6px 0;'+(i?'border-top:1px solid '+IVC.bord+';':''); r.innerHTML='<span style="color:'+IVC.mut+';font-size:13px;">'+f[0]+'</span><div style="margin-top:2px;">'+f[1]+'</div>'; fg.appendChild(r); }); 12252 root.appendChild(fg); 12253 } 12254 12255 // Units 12256 var units=unitsForProp(p.id); 12257 if(units.length){ 12258 root.appendChild(mkDivSafe('font-size:13px;color:'+IVC.mut+';margin:2px 0 6px;','Units')); 12259 units.forEach(function(u){ 12260 var ur=document.createElement('div'); ur.style.cssText='display:flex;justify-content:space-between;align-items:center;gap:8px;background:'+IVC.card+';border:1px solid '+IVC.bord+';border-radius:8px;padding:9px 12px;margin-bottom:6px;'; 12261 ur.innerHTML='<div><b>'+_esc(u.label)+'</b> â '+invMoney(u.rent_amount)+'/mo'+(u.status==='vacant'?' <span style="color:'+IVC.red+';">(vacant)</span>':(u.tenant_name?(' · '+_esc(u.tenant_name)):''))+(u.lease_end?('<div style="font-size:12px;color:'+IVC.mut+';">Lease ends '+_esc(u.lease_end)+'</div>'):'')+'</div>'; 12262 var ua=document.createElement('div'); ua.style.cssText='display:flex;gap:6px;'; 12263 ua.appendChild(ivb('Edit','sm',(function(u2){return function(){ openInvUnitForm(u2.property_id,u2); };})(u))); 12264 ua.appendChild(ivb('Del','sm danger',(function(u2){return function(){ if(confirm('Delete unit "'+u2.label+'"?')){ delInvUnit(u2.id); openInvPropertyDetail(pid); } };})(u))); 12265 ur.appendChild(ua); root.appendChild(ur); 12266 }); 12267 } 12268 12269 // Rent expected vs actual for the current month (the manager-controlled rent side). 12270 (function(){ 12271 var ymC=invCurMonth(); 12272 var expDep=propExpectedDeposit(p.id); if(expDep<=0) return; 12273 var got=propRentReceived(p.id, ymC); var short=propRentShortfall(p.id, ymC); var expProfit=propExpectedProfit(p.id); 12274 var box=document.createElement('div'); box.style.cssText='background:'+IVC.card+';border:1px solid '+IVC.bord+';border-radius:12px;padding:12px 16px;margin:16px 0 6px;'; 12275 box.appendChild(mkDivSafe('font-size:13px;color:'+IVC.mut+';margin-bottom:6px;','Rent â expected vs actual · '+_esc(invMonthLabel(ymC)))); 12276 function row(l,v,c){ var r=document.createElement('div'); r.style.cssText='display:flex;justify-content:space-between;padding:4px 0;'; r.innerHTML='<span style="color:'+IVC.mut+';">'+l+'</span><span style="color:'+(c||IVC.txt)+';font-weight:500;">'+v+'</span>'; return r; } 12277 box.appendChild(row('Expected deposit', invMoney(expDep))); 12278 box.appendChild(row('Actually received', got>0?invMoney(got):'not yet', got>0?(short>0?IVC.warn:IVC.grn):IVC.warn)); 12279 if(short>0) box.appendChild(row('Shortfall booked as repair/maintenance', invMoney(short), IVC.red)); 12280 box.appendChild(row('Expected profit (rent â mgmt)', invMoney(expProfit))); 12281 root.appendChild(box); 12282 })(); 12283 12284 // Date-range picker (blank ends = open). Quick presets + From/To. 12285 var mine=ILED.filter(function(l){ return String(l.property_id)===String(p.id); }); 12286 var rp=document.createElement('div'); rp.style.cssText='display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:16px 0 6px;'; 12287 rp.appendChild(mkDivSafe('font-size:13px;color:'+IVC.mut+';','From')); 12288 var fromI=document.createElement('input'); fromI.type='date'; fromI.value=_invDetailFrom||''; rp.appendChild(fromI); 12289 rp.appendChild(mkDivSafe('font-size:13px;color:'+IVC.mut+';','to')); 12290 var toI=document.createElement('input'); toI.type='date'; toI.value=_invDetailTo||''; rp.appendChild(toI); 12291 fromI.addEventListener('change',function(){ _invDetailFrom=fromI.value; openInvPropertyDetail(pid); }); 12292 toI.addEventListener('change',function(){ _invDetailTo=toI.value; openInvPropertyDetail(pid); }); 12293 var yr=new Date().getFullYear(); 12294 rp.appendChild(ivb('This month','sm',function(){ var m=new Date(); _invDetailFrom=m.getFullYear()+'-'+String(m.getMonth()+1).padStart(2,'0')+'-01'; _invDetailTo=''; openInvPropertyDetail(pid); })); 12295 rp.appendChild(ivb('YTD','sm',function(){ _invDetailFrom=yr+'-01-01'; _invDetailTo=''; openInvPropertyDetail(pid); })); 12296 rp.appendChild(ivb('Clear','sm',function(){ _invDetailFrom=''; _invDetailTo=''; openInvPropertyDetail(pid); })); 12297 root.appendChild(rp); 12298 var fmine = mine.filter(function(l){ var dt=String(l.date||'').slice(0,10); if(_invDetailFrom && dt<_invDetailFrom) return false; if(_invDetailTo && dt>_invDetailTo) return false; return true; }); 12299 var selLabel = (_invDetailFrom||_invDetailTo) ? ((_invDetailFrom?fd(_invDetailFrom):'start')+' â '+(_invDetailTo?fd(_invDetailTo):'now')) : 'all time'; 12300 12301 // Category totals (for the selected months) 12302 var byCat={}, inc=0, exp=0; 12303 fmine.forEach(function(l){ var d=(l.direction||invDirFor(l.category)); var a=invNum(l.amount); byCat[l.category]=(byCat[l.category]||0)+(d==='income'?a:-a); if(d==='income') inc+=a; else exp+=a; }); 12304 var cats=Object.keys(byCat); 12305 if(cats.length){ 12306 root.appendChild(mkDivSafe('font-size:13px;color:'+IVC.mut+';margin:14px 0 6px;','Totals by category â '+_esc(selLabel))); 12307 var cg=document.createElement('div'); cg.style.cssText='background:'+IVC.card+';border:1px solid '+IVC.bord+';border-radius:12px;padding:6px 14px;margin-bottom:6px;'; 12308 cats.sort(function(a,b){ return Math.abs(byCat[b])-Math.abs(byCat[a]); }
12308).forEach(function(c,i){ 12309 var v=byCat[c]; var r=document.createElement('div'); r.style.cssText='display:flex;justify-content:space-between;padding:6px 0;'+(i?'border-top:1px solid '+IVC.bord+';':''); 12310 r.innerHTML='<span>'+_esc(c)+'</span><span style="color:'+(v>=0?IVC.grn:IVC.red)+';">'+(v>=0?'+':'â')+invMoney(v).replace('-','')+'</span>'; cg.appendChild(r); 12311 }); 12312 var nr=document.createElement('div'); nr.style.cssText='display:flex;justify-content:space-between;padding:8px 0;border-top:1px solid '+IVC.bord+';font-weight:500;'; 12313 nr.innerHTML='<span>Net</span><span style="color:'+((inc-exp)>=0?IVC.grn:IVC.red)+';">'+invMoney(inc-exp)+'</span>'; cg.appendChild(nr); 12314 root.appendChild(cg); 12315 } 12316 12317 // Ledger for this property (selected months) 12318 root.appendChild(mkDivSafe('font-size:13px;color:'+IVC.mut+';margin:14px 0 6px;','Ledger â '+_esc(selLabel))); 12319 var rows=fmine.slice().sort(function(a,b){ return (b.date||'').localeCompare(a.date||''); }); 12320 var tbl=document.createElement('table'); 12321 tbl.innerHTML='<thead><tr><th style="width:62px;">Date</th><th>Category</th><th>Note</th><th style="text-align:right;width:96px;">Amount</th><th style="width:26px;"></th></tr></thead>'; 12322 var tb=document.createElement('tbody'); 12323 if(!rows.length){ var e0=document.createElement('tr'); e0.innerHTML='<td colspan="5" style="color:'+IVC.mut+';">No entries yet.</td>'; tb.appendChild(e0); } 12324 rows.forEach(function(l){ 12325 var dir=(l.direction||invDirFor(l.category)); var col=dir==='income'?IVC.grn:IVC.red; var sign=dir==='income'?'+':'â'; 12326 var tr=document.createElement('tr'); tr.style.cursor='pointer'; 12327 var due2=l.due_date?(' <span style="font-size:11px;color:'+IVC.warn+';background:'+IVC.warnbg+';padding:1px 6px;border-radius:6px;white-space:nowrap;">Due '+_esc(fd(l.due_date))+'</span>'):''; 12328 tr.innerHTML='<td>'+_esc(fd(l.date))+'</td><td>'+_esc(l.category)+'</td><td style="color:'+IVC.mut+';font-size:13px;">'+_esc(l.notes||l.payee||'')+due2+'</td><td style="text-align:right;color:'+col+';">'+sign+invMoney(l.amount).replace('-','')+'</td>'; 12329 var tdx=document.createElement('td'); tdx.style.textAlign='center'; var xb=document.createElement('span'); xb.textContent='Ã'; xb.style.cssText='color:'+IVC.mut+';cursor:pointer;font-size:16px;'; 12330 (function(l2){ xb.addEventListener('click',function(e){ e.stopPropagation(); if(confirm('Delete this entry?')){ delInvLedger(l2.id); openInvPropertyDetail(pid); } }); })(l); 12331 tdx.appendChild(xb); tr.appendChild(tdx); 12332 (function(l2){ tr.addEventListener('click',function(){ openInvLedgerForm(l2); }); })(l); 12333 tb.appendChild(tr); 12334 }); 12335 tbl.appendChild(tb); root.appendChild(tbl); 12336} 12337 12338// ---- CSV backfill: date,property,category,amount,payee,notes ---- 12339function invImportCsv(){ 12340 var input=document.createElement('input'); input.type='file'; input.accept='.csv,text/csv'; input.style.display='none'; document.body.appendChild(input); 12341 input.addEventListener('change', function(){ 12342 var f=input.files&&input.files[0]; if(!f){ document.body.removeChild(input); return; } 12343 var rd=new FileReader(); 12344 rd.onload=function(){
12345 try{ 12346 var lines=String(rd.result).split(/\r?\n/).filter(function(x){return x.trim();}); 12347 if(lines.length<2){ alert('CSV needs a header row and at least one data row.'); return; } 12348 var hdr=lines[0].split(',').map(function(s){return s.trim().toLowerCase();}); 12349 function col(row,name){ var i=hdr.indexOf(name); return i>=0?(row[i]||'').trim():''; } 12350 var added=0; 12351 for(var i=1;i<lines.length;i++){ 12352 var row=lines[i].split(','); 12353 var cat=col(row,'category')||'Other'; var amt=invNum(col(row,'amount')); if(amt<=0) continue; 12354 var pname=col(row,'property'); var prop=IPROP.find(function(p){ return (p.name||'').toLowerCase()===pname.toLowerCase() || (p.address||'').toLowerCase().indexOf(pname.toLowerCase())>=0; }); 12355 var rec={ id:Date.now()+Math.floor(Math.random()*100000)+i, date:col(row,'date')||tod(), property_id:prop?prop.id:null, unit_id:null, hoa_id:null, category:cat, direction:invDirFor(cat), amount:amt, payee:col(row,'payee'), notes:col(row,'notes'), source:'csv' }; 12356 ILED.push(rec); saveInvLedger(rec); added++; 12357 } 12358 alert('Imported '+added+' ledger entries.'); renderInvestments(); 12359 }catch(e){ alert('Could not read that CSV: '+e.message); } 12360 try{ document.body.removeChild(input); }catch(_){} 12361 }; 12362 rd.readAsText(f); 12363 }); 12364 input.click(); 12365} 12366// ---- AI finance-email extraction ---- 12367function _invFinanceQuery(label, since){ 12368 var terms = ['rent','HOA','"homeowners association"','mortgage','"mortgage statement"','statement','payment','invoice','utility','utilities','insurance','escrow','"property tax"']; 12369 IPROP.forEach(function(p){ if(p.address) terms.push('"'+p.address.replace(/"/g,'')+'"'); if(p.name) terms.push('"'+p.name.replace(/"/g,'')+'"'); }); 12370 if(label && label.trim()) terms.push('label:'+label.trim().replace(/\s+/g,'-')); 12371 // Only look back as far as the bookmark ("since") so repeat scans don't re-read the whole year. 12372 var range = (since && /^\d{4}-\d{2}-\d{2}$/.test(since)) ? ('after:'+since.replace(/-/g,'/')) : 'newer_than:365d'; 12373 return '(' + terms.join(' OR ') + ') ' + range; 12374} 12375// Content-based dedupe key: the same charge arriving in two mailboxes (or re-scanned) collapses. 12376function _invEref(o){ return [(o.date||''), invNum(o.amount), (o.category||''), (o.property_id||'')].join('|'); } 12377function _invConnectedMembers(){ 12378 if(typeof gmailState==='undefined' || !gmailState.connectedAccounts) return []; 12379 return Object.keys(gmailState.connectedAccounts).map(function(mid){ 12380 var tm = TM.find(function(t){ return String(t.id)===String(mid); }); 12381 var acct = gmailState.connectedAccounts[mid] || {}; 12382 return { id:mid, label:(acct.email || (tm?(tm.first+' '+tm.last):('Account '+mid))) }; 12383 }); 12384} 12385function _invNormCat(c){ 12386 c = String(c||'').trim().toLowerCase(); 12387 var all = INV_INCOME_CATS.concat(INV_EXPENSE_CATS); 12388 for(var i=0;i<all.length;i++){ if(all[i].toLowerCase()===c) return all[i]; } 12389 if(c.indexOf('rent')>=0) return 'Rent'; 12390 if(c.indexOf('hoa')>=0||c.indexOf('associ')>=0) return 'HOA'; 12391 if(c.indexOf('mort')>=0||c.indexOf('loan')>=0) return 'Mortgage'; 12392 if(c.indexOf('util')>=0||c.indexOf('electric')>=0||c.indexOf('water')>=0||c.indexOf('gas')>=0) return 'Utilities'; 12393 if(c.indexOf('insur')>=0) return 'Insurance'; 12394 if(c.indexOf('tax')>=0) return 'Property Tax'; 12395 if(c.indexOf('repair')>=0||c.indexOf('mainten')>=0) return 'Repairs'; 12396 if(c.indexOf('manage')>=0) return 'Management'; 12397 return 'Other'; 12398} 12399function _invMatchProp(text){ 12400 text = String(text||'').toLowerCase(); if(!text) return null; 12401 for(var i=0;i<IPROP.length;i++){ var p=IPROP[i]; 12402 if(p.name && text.indexOf(String(p.name).toLowerCase())>=0) return p; 12403 if(p.address && (text.indexOf(String(p.address).toLowerCase())>=0 || String(p.address).toLowerCase().indexOf(text)>=0)) return p; 12404 } 12405 return null; 12406} 12407function _invParseArr(txt){ 12408 try{ return JSON.parse(txt); }catch(e){} 12409 var a=txt.indexOf('['), b=txt.lastIndexOf(']'); 12410 if(a>=0 && b>a){ try{ return JSON.parse(txt.slice(a,b+1)); }catch(e){} } 12411 return null; 12412} 12413async function scanFinanceEmails(){
12414 // Trigger the trustworthy server-side pipeline: ONE mailbox, the Payees whitelist, message-id 12415 // dedupe, and the Fresh Start PDF split. (The old broad-keyword client scan has been retired.) 12416 if(!IPROP.length){ alert('Add at least one property first.'); return; } 12417 var activePayees = IPAYEE.filter(function(p){ return p.active!==false; }); 12418 if(!activePayees.length){ if(!confirm('No approved payees yet, so nothing will be booked.\n\nAdd your manager, HOA, utilities and tax in Payees first. Open Payees now?')){ return; } openInvPayeesModal(); return; } 12419 var ov=document.createElement('div'); ov.className='modal-ov open'; ov.style.zIndex='1300'; 12420 var m=document.createElement('div'); m.className='modal'; m.style.maxWidth='520px'; 12421 m.innerHTML='<div style="padding:16px 20px;border-bottom:1px solid var(--border);font-weight:700;font-size:17px;">Scan finance emails</div>'; 12422 var body=document.createElement('div'); body.style.cssText='padding:18px 20px;'; m.appendChild(body); 12423 var ft=document.createElement('div'); ft.style.cssText='display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--border);'; m.appendChild(ft); 12424 var closeBtn=document.createElement('button'); closeBtn.className='btn btn-g'; closeBtn.textContent='Close'; closeBtn.addEventListener('click',function(){ try{document.body.removeChild(ov);}catch(e){} }); ft.appendChild(closeBtn); 12425 ov.appendChild(m); document.body.appendChild(ov); 12426 body.innerHTML='<div style="text-align:center;color:var(--text3);padding:16px;">Reading approved finance emails and splitting statementsâ¦</div>'; 12427 try{ 12428 var resp=await fetch('/api/scan-rentals',{ method:'POST', headers:await apiHeaders() }); 12429 var raw=''; try{ raw=await resp.text(); }catch(e){} 12430 var d={}; try{ d=JSON.parse(raw); }catch(e){} 12431 if(!resp.ok || d.error){ 12432 var detail=(d&&d.error)||(raw?raw.slice(0,300):('HTTP '+resp.status)); 12433 var hint=(resp.status===504||/timeout|FUNCTION_INVOCATION/i.test(raw))?'<div style="font-size:12px;color:var(--text3);margin-top:6px;">Looks like the scan ran long. It keeps working in the background â try again in a moment; already-booked emails won\'t double-post.</div>':''; 12434 body.innerHTML='<div style="color:var(--danger);padding:8px;">Scan failed ('+resp.status+'): '+_esc(detail)+'</div>'+hint; return; 12435 } 12436 await loadFromDB(); 12437 body.innerHTML=''; 12438 body.style.maxHeight='72vh'; body.style.overflow='auto'; 12439 body.appendChild(mkDivSafe('font-size:15px;margin-bottom:6px;','<b>'+(d.added||0)+'</b> new entr'+((d.added===1)?'y':'ies')+' added.')); 12440 var bits=[]; 12441 if(d.skipped) bits.push(d.skipped+' already recorded'); 12442 if(d.unmatched) bits.push(d.unmatched+' ignored (not an approved payee)'); 12443 if(d.freshStartPending) bits.push(d.freshStartPending+' statement'+(d.freshStartPending===1?'':'s')+' with no PDF'); 12444 if(bits.length) body.appendChild(mkDivSafe('font-size:13px;color:var(--text3);margin-bottom:4px;', _esc(bits.join(' · ')))); 12445 if(d.note) body.appendChild(mkDivSafe('font-size:13px;color:var(--warn);margin:6px 0;', _esc(d.note))); 12446 if(Array.isArray(d.errors)&&d.errors.length) body.appendChild(mkDivSafe('font-size:12px;color:var(--text3);margin:6px 0;', 'Notes: '+_esc(d.errors.join(' | ')))); 12447 // Sender parsing for the review sections + one-click "add as payee". 12448 function _sndEmail(f){ var m=String(f||'').match(/<([^>]+)>/); return (m?m[1]:String(f||'')).trim(); } 12449 function _sndName(f){ var m=String(f||'').match(/^\s*"?([^"<]+?)"?\s*</); return m?m[1].trim():''; } 12450 function _sndDomain(f){ var e=_sndEmail(f); var i=e.indexOf('@'); return i>=0?e.slice(i):e; } 12451 // Does this sender already match an active payee? (mirrors the server whitelist) 12452 function _payeeForText(txt){ 12453 var hay=String(txt||'').toLowerCase(); 12454 for(var i=0;i<IPAYEE.length;i++){ var p=IPAYEE[i]; if(p.active===false) continue; 12455 var terms=String(p.match||'').toLowerCase().split(/[,\n|]+/).map(function(s){return s.trim();}).filter(Boolean); 12456 if(terms.some(function(t){ return hay.indexOf(t)>=0; })) return p; 12457 } 12458 return null; 12459 } 12460 body.appendChild(mkDivSafe('font-size:12px;color:var(--text3);margin:10px 0 2px;','<span style="color:var(--danger);">Red</span> = no matching payee yet · default = we have a payee')); 12461 function reviewSection(title, arr, kind, color){ 12462 if(!Array.isArray(arr)||!arr.length) return; 12463 body.appendChild(mkDivSafe('font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:'+color+';font-weight:700;margin:14px 0 4px;', _esc(title)+' ('+arr.length+')')); 12464 arr.forEach(function(it){ 12465 var pm=_payeeForText((it.from||'')+' '+(it.subject||'')); // matched payee, or null if missing 12466 var row=document.createElement('div'); row.style.cssText='display:flex;justify-content:space-between;gap:8px;align-items:flex-start;padding:6px 0;border-bottom:1px solid var(--border);'; 12467 var left=document.createElement('div'); left.style.cssText='min-width:0;flex:1;'; 12468 var sndColor = pm ? 'var(--text)' : 'var(--danger)'; 12469 left.innerHTML='<div style="font-size:13px;color:'+sndColor+';font-weight:'+(pm?'400':'600')+';overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">'+_esc(_sndEmail(it.from)||it.from||'(unknown sender)')+'</div>' 12470 +'<div style="font-size:12px;color:var(--text3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">'+_esc(it.subject||'(no subject)')+(it.date?(' · '+_esc(it.date)):'')+'</div>'; 12471 row.appendChild(left); 12472 if(pm){ 12473 row.appendChild(mkDivSafe('font-size:12px;color:var(--lead,#1a7f37);flex-shrink:0;white-space:nowrap;align-self:center;', 'â '+_esc(pm.name||'payee'))); 12474 } else { 12475 var b=document.createElement('button'); b.className='btn btn-p'; b.style.cssText='font-size:12px;padding:3px 9px;flex-shrink:0;'; b.textContent='+ Payee'; 12476 (function(it2,btn){ btn.addEventListener('click',function(){ openInvPayeeForm(null, function(){ btn.textContent='Added â';
12476 btn.disabled=true; }, { name:(_sndName(it2.from)||_sndDomain(it2.from)), match:_sndDomain(it2.from), kind:kind }); }); })(it,b); 12477 row.appendChild(b); 12478 } 12479 body.appendChild(row); 12480 }); 12481 } 12482 reviewSection('Ignored â not an approved payee', d.ignored, 'expense', 'var(--warn)'); 12483 reviewSection('Statements with no PDF', d.noPdf, 'rent', 'var(--text3)'); 12484 reviewSection('Already recorded', d.recorded, 'expense', 'var(--text3)'); 12485 renderInvestments(); 12486 }catch(e){ body.innerHTML='<div style="color:var(--danger);padding:8px;">Scan failed: '+_esc(e.message||String(e))+'</div>'; } 12487} 12488// ============================================================================ 12489// DEAL SHEET (Seller Net Sheet) â faithful rebuild of the Excel, editable + PDF export 12490// ============================================================================ 12491// Title-policy rate table (Eagle Owner column), looked up by sales price (approximate: largest 12492// threshold <= price). Extracted straight from the workbook; verified against its own numbers. 12493var DS_TITLE=[[0.0,242],[10000.01,291],[20000.01,338],[30000.01,385],[40000.01,433],[50000.01,490],[60000.01,546],[70000.01,603],[80000.01,659],[90000.01,717],[100000.01,768],[110000.01,820],[120000.01,872],[130000.01,922],[140000.01,974],[150000.01,1026],[160000.01,1077],[170000.01,1128],[180000.01,1180],[190000.01,1231],[200000.01,1273],[210000.01,1314],[220000.01,1356],[230000.01,1396],[240000.01,1437],[250000.01,1479],[260000.01,1520],[270000.01,1561],[280000.01,1602],[290000.01,1643],[300000.01,1685],[310000.01,1725],[320000.01,1767],[330000.01,1808],[340000.01,1848],[350000.01,1890],[360000.01,1931],[370000.01,1973],[380000.01,2013],[390000.01,2054],[400000.01,2096],[410000.01,2137],[420000.01,2178],[430000.01,2219],[440000.01,2260],[450000.01,2302],[460000.01,2342],[470000.01,2384],[480000.01,2425],[490000.01,2466],[500000.01,2486],[510000.01,2507],[520000.01,2528],[530000.01,2548],[540000.01,2569],[550000.01,2590],[560000.01,2610],[570000.01,2631],[580000.01,2651],[590000.01,2671],[600000.01,2692],[610000.01,2713],[620000.01,2734],[630000.01,2754],[640000.01,2775],[650000.01,2796],[660000.01,2815],[670000.01,2836],[680000.01,2857],[690000.01,2877],[700000.01,2898],[710000.01,2919],[720000.01,2940],[730000.01,2959],[740000.01,2980],[750000.01,3001],[760000.01,3021],[770000.01,3042],[780000.01,3063],[790000.01,3083],[800000.01,3104],[810000.01,3124],[820000.01,3145],[830000.01,3165],[840000.01,3186],[850000.01,3207],[860000.01,3227],[870000.01,3248],[880000.01,3269],[890000.01,3288],[900000.01,3309],[910000.01,3330],[920000.01,3351],[930000.01,3371],[940000.01,3392],[950000.01,3413],[960000.01,3432],[970000.01,3453],[980000.01,3474],[990000.01,3494],[1000000.01,3515],[1010000.01,3536],[1020000.01,3557],[1030000.01,3577],[1040000.01,3597],[1050000.01,3618],[1060000.01,3638],[1070000.01,3659],[1080000.01,3680],[1090000.01,3700],[1100000.01,3721],[1110000.01,3742],[1120000.01,3762],[1130000.01,3782],[1140000.01,3803],[1150000.01,3824],[1160000.01,3844],[1170000.01,3865],[1180000.01,3886],[1190000.01,3905],[1200000.01,3926],[1210000.01,3947],[1220000.01,3968],[1230000.01,3988],[1240000.01,4009],[1250000.01,4030],[1260000.01,4050],[1270000.01,4070],[1280000.01,4091],[1290000.01,4111],[1300000.01,4132],[1310000.01,4153],[1320000.01,4174],[1330000.01,4194],[1340000.01,4215],[1350000.01,4235],[1360000.01,4255],[1370000.01,4276],[1380000.01,4297],[1390000.01,4317],[1400000.01,4338],[1410000.01,4359],[1420000.01,4380],[1430000.01,4399],[1440000.01,4420],[1450000.01,4441],[1460000.01,4461],[1470000.01,4482],[1480000.01,4503],[1490000.01,4523],[1500000.01,4543],[1510000.01,4564],[1520000.01,4585],[1530000.01,4605],[1540000.01,4626],[1550000.01,4647]]; 12494function dsNum(v){ var n=parseFloat(String(v==null?'':v).replace(/[^0-9.\-]/g,'')); return isNaN(n)?0:n; } 12495function dsM(n){ n=dsNum(n); return '$'+n.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2}); } 12496function dsLongDate(iso){ if(!iso) return ''; var d=new Date(iso+'T00:00:00'); if(isNaN(d.getTime())) return iso; return d.toLocaleDateString('en-US',{month:'long',day:'numeric',year:'numeric'}); } 12497function dsTitlePolicy(price){ if(price<=0) return 0; var fee=0; for(var i=0;i<DS_TITLE.length;i++){ if(DS_TITLE[i][0]<=price) fee=DS_TITLE[i][1]; else break; } return fee; } 12498function dsDaysInYear(y){ return Math.round((new Date(y,11,31)-new Date(y,0,1))/86400000)+1; } 12499function dsDayOfYear(d){ return Math.round((d-new Date(d.getFullYear(),0,1))/86400000)+1; } 12500function dsScenarioDefault(){ return { salesPrice:'', mtg1:'', mtg2:'', otherLiens:'', commissionPct:5, otherFees:'', homeWarranty:'', utilities:'', concessions:'', otherLabel:'Other', otherAmount:'' }; } 12501function dsDefault(){ return { id: Date.now()+Math.floor(Math.random()*100000), contact_id:null, name:'', 12502 data:{ seller:'', address:'', annualTaxes:'', hoaFees:'', closingDate:'', 12503 agent:'Elda Palacios Baker & Randy Baker', agentPhone:'801-706-3806 & 801-910-2296', 12504 brokerage:'Wise Choice Real Estate', brokerageAddr:'193 E Fort Union Blvd #202, Midvale, UT 84047', email:'[email protected] & [email protected]', 12505 scenarios:[ dsScenarioDefault(), dsScenarioDefault() ] } }; } 12506// Per-scenario calculated figures (mirrors the Excel formulas exactly). 12507function dsCompute(sh){ 12508 var d=sh.data||{};
12508 var annualTax=dsNum(d.annualTaxes), hoaMonthly=dsNum(d.hoaFees); 12509 var cd = d.closingDate ? new Date(d.closingDate+'T00:00:00') : null; if(cd && isNaN(cd.getTime())) cd=null; 12510 return (d.scenarios||[]).map(function(s){ 12511 var price=dsNum(s.salesPrice); var r={price:price}; 12512 var commission = price>0 ? price*(dsNum(s.commissionPct)/100) : 0; 12513 var proTax=0, proHOA=0; 12514 if(price>0 && cd){ var y=cd.getFullYear(); 12515 proTax = annualTax/dsDaysInYear(y) * dsDayOfYear(cd); 12516 proHOA = hoaMonthly>0 ? (hoaMonthly/new Date(y,cd.getMonth()+1,0).getDate())*cd.getDate() : 0; } 12517 var settlement = price>0 ? 400 : 0; 12518 var title = price>0 ? dsTitlePolicy(price) : 0; 12519 var recon = price>0 ? (95 + (dsNum(s.mtg2)>0?75:0)) : 0; 12520 var secure = 0; 12521 var costs = dsNum(s.mtg1)+dsNum(s.mtg2)+dsNum(s.otherLiens)+commission+dsNum(s.otherFees)+proTax+proHOA+settlement+title+recon+secure+dsNum(s.homeWarranty)+dsNum(s.utilities)+dsNum(s.concessions)+dsNum(s.otherAmount); 12522 r.commission=commission; r.proTax=proTax; r.proHOA=proHOA; r.settlement=settlement; r.title=title; r.recon=recon; r.secure=secure; 12523 r.net = price>0 ? price-costs : 0; 12524 return r; 12525 }); 12526} 12527function _dsStyle(){ 12528 if(ge('dsCss')) return; 12529 var s=document.createElement('style'); s.id='dsCss'; 12530 s.textContent= 12531 '#dsSheet{background:#fff;color:#1a1a1a;max-width:820px;margin:0 auto;padding:24px 28px;font-family:Arial,Helvetica,sans-serif;border:1px solid #ddd;}' 12532 +'#dsSheet .ds-hdr{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:6px;}' 12533 +'#dsSheet .ds-hdr img.l1{height:96px;}' 12534 +'#dsSheet .ds-hdr img.l2{height:56px;}' 12535 +'#dsSheet .ds-agent{font-size:11px;color:#333;text-align:center;line-height:1.5;}' 12536 +'#dsSheet .ds-agent .nm{font-size:15px;font-weight:700;margin-bottom:2px;display:block;}' 12537 +'#dsSheet .ds-title{font-size:26px;font-weight:700;text-align:center;margin:6px 0 4px;}' 12538 +'#dsSheet .ds-disc{font-size:10.5px;color:#cc0000;text-align:center;margin-bottom:12px;line-height:1.4;}' 12539 +'#dsSheet .ds-row{display:flex;gap:12px;margin:3px 0;font-size:13px;align-items:center;}' 12540 +'#dsSheet .ds-lbl{font-weight:700;color:#222;}' 12541 +'#dsSheet input.dsi{background:#FFFF99;border:1px solid #d9c94a;border-radius:3px;padding:5px 7px 7px;line-height:1.5;height:auto;box-sizing:border-box;font-family:inherit;font-size:13px;color:#1a1a1a;}' 12542 +'#dsSheet table.ds-t{width:640px;max-width:100%;border-collapse:collapse;margin:12px auto;font-size:13px;}' 12543 +'#dsSheet table.ds-t td{padding:5px 8px;border-bottom:1px solid #eee;vertical-align:middle;}' 12544 +'#dsSheet table.ds-t td.c{text-align:center;width:27%;} #dsSheet table.ds-t td.lbl{font-weight:600;color:#222;width:46%;padding-left:14px;}' 12545 +'#dsSheet .ds-scenhdr{background:#1F497D;color:#fff;font-weight:700;text-align:center;padding:5px;}' 12546 +'#dsSheet .ds-net td{border-top:2px solid #1F497D;font-weight:700;font-size:15px;background:#FFF7D6;}' 12547 +'#dsSheet .ds-foot{font-size:10px;color:#666;margin-top:12px;line-height:1.5;}' 12548 +'#page-dealsheet .dsbtn{background:var(--accent);color:#fff;border:none;border-radius:7px;padding:7px 14px;font-family:inherit;font-size:14px;cursor:pointer;}' 12549 +'#page-dealsheet .dsbtn.g{background:transparent;border:1px solid var(--border);color:var(--text);}'; 12550 document.head.appendChild(s); 12551} 12552var _dsCur=null; 12553function renderDealSheets(){ 12554 _dsStyle(); var root=ge('dsRoot'); if(!root) return; root.innerHTML=''; _dsCur=null; 12555 var bar=document.createElement('div'); bar.style.cssText='display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;'; 12556 bar.appendChild(mkDivSafe('font-size:22px;font-weight:600;','Seller Net Sheets')); 12557 var nb=document.createElement('button'); nb.className='dsbtn'; nb.textContent='+ New sheet'; nb.addEventListener('click',function(){ openDealSheet(dsDefault(), true); }); 12558 bar.appendChild(nb); root.appendChild(bar); 12559 if(!DSHEET.length){ root.appendChild(mkDivSafe('color:var(--text3);padding:20px 0;','No saved sheets yet. Create one, fill it in, and export a PDF to send your client.')); return; } 12560 var list=document.createElement('div'); list.style.cssText='display:flex;flex-direction:column;gap:8px;'; 12561 DSHEET.slice().sort(function(a,b){ return String(b.updated_at||'').localeCompare(String(a.updated_at||'')); }
12561).forEach(function(sh){ 12562 var c=gc(sh.contact_id); 12563 var row=document.createElement('div'); row.style.cssText='display:flex;justify-content:space-between;align-items:center;gap:8px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:10px 14px;cursor:pointer;'; 12564 row.innerHTML='<div><div style="font-weight:600;">'+_esc(sh.name||(sh.data&&sh.data.address)||'Untitled sheet')+'</div><div style="font-size:12px;color:var(--text3);">'+_esc((c?fn(c):(sh.data&&sh.data.seller)||'')+(sh.updated_at?(' · updated '+fd(sh.updated_at)):''))+'</div></div>'; 12565 var del=document.createElement('button'); del.className='dsbtn g'; del.style.cssText='font-size:12px;padding:3px 9px;'; del.textContent='Delete'; 12566 (function(id){ del.addEventListener('click',function(e){ e.stopPropagation(); if(confirm('Delete this sheet?')){ delDealSheet(id); renderDealSheets(); } }); })(sh.id); 12567 row.addEventListener('click',function(){ openDealSheet(sh, false); }); 12568 row.appendChild(del); list.appendChild(row); 12569 }); 12570 root.appendChild(list); 12571} 12572function openDealSheet(sh, isNew){ 12573 _dsStyle(); _dsCur=sh; var root=ge('dsRoot'); if(!root) return; root.innerHTML=''; 12574 var d=sh.data; d.scenarios = d.scenarios && d.scenarios.length ? d.scenarios : [dsScenarioDefault(),dsScenarioDefault()]; 12575 // Toolbar 12576 var tb=document.createElement('div'); tb.style.cssText='display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px;'; 12577 var back=document.createElement('button'); back.className='dsbtn g'; back.textContent='â¹ Back'; back.addEventListener('click',renderDealSheets); tb.appendChild(back); 12578 var cWrap=document.createElement('div'); cWrap.style.cssText='min-width:240px;'; tb.appendChild(cWrap); 12579 var picker=buildContactPicker(cWrap,'ds_client','Load client (search name / address)â¦',function(){ 12580 var id=picker.hidden.value; if(!id) return; var c=gc(parseInt(id)); if(!c) return; 12581 sh.contact_id=parseInt(id); d.seller=fn(c); d.address=c.property||c.address||d.address; 12582 if(!sh.name) sh.name=d.address||d.seller; 12583 openDealSheet(sh,isNew); 12584 }); 12585 var saveB=document.createElement('button'); saveB.className='dsbtn'; saveB.textContent='Save'; saveB.addEventListener('click',function(){ 12586 if(!sh.name) sh.name=d.address||d.seller||'Seller Net Sheet'; 12587 if(!DSHEET.some(function(x){return String(x.id)===String(sh.id);})) DSHEET.push(sh); 12588 saveDealSheet(sh); saveB.textContent='Saved â'; setTimeout(function(){saveB.textContent='Save';},1500); 12589 }); tb.appendChild(saveB); 12590 var pdfB=document.createElement('button'); pdfB.className='dsbtn'; pdfB.textContent='Export PDF'; pdfB.addEventListener('click',function(){ dsExportPDF(sh, pdfB); }); tb.appendChild(pdfB); 12591 var pngB=document.createElement('button'); pngB.className='dsbtn g'; pngB.textContent='Save PNG'; pngB.addEventListener('click',function(){ dsExportPNG(sh, pngB); }); tb.appendChild(pngB); 12592 root.appendChild(tb); 12593 12594 // The sheet 12595 var sheet=document.createElement('div'); sheet.id='dsSheet'; 12596 var hdr=document.createElement('div'); hdr.className='ds-hdr'; 12597 hdr.innerHTML='<img class="l1" src="pbre-logo.png" alt="Palacios Baker Real Estate">' 12598 +'<div class="ds-agent"><span class="nm">'+_esc(d.agent)+'</span>'+_esc(d.brokerage)+'<br>'+_esc(d.brokerageAddr||'')+'<br>'+_esc(d.agentPhone)+'<br>'+_esc(d.email)+'</div>' 12599 +'<img class="l2" src="pbre-logo2.png" alt="">'; 12600 sheet.appendChild(hdr); 12601 sheet.appendChild(mkDivSafe('','<div class="ds-title">Seller Net Sheet</div>')); 12602 sheet.appendChild(mkDivSafe('','<div class="ds-disc">This is an estimate and fees can vary.</div>')); 12603 12604 function field(key, ph, type){ var i=document.createElement('input'); i.className='dsi'; i.type=type||'text'; i.value=(d[key]!=null?d[key]:''); if(ph)i.placeholder=ph; i.style.flex='1'; 12605 i.addEventListener('input',function(){ d[key]=i.value; dsRefresh(sh); }); return i; } 12606 function rowLbl(lbl, el, lbl2, el2){ var r=document.createElement('div'); r.className='ds-row'; var l=document.createElement('span'); l.className='ds-lbl'; l.textContent=lbl; l.style.minWidth='130px'; r.appendChild(l); if(el)r.appendChild(el); 12607 if(lbl2){ var l2=document.createElement('span'); l2.className='ds-lbl'; l2.textContent=lbl2; l2.style.marginLeft='10px'; r.appendChild(l2);} if(el2)r.appendChild(el2); return r; } 12608 sheet.appendChild(rowLbl('Seller:', field('seller','Seller name'))); 12609 sheet.appendChild(rowLbl('Property Address:', field('address','Property address'))); 12610 var taxI=field('annualTaxes','',''); taxI.style.maxWidth='120px'; var hoaI=field('hoaFees','',''); hoaI.style.maxWidth='120px'; 12611 sheet.appendChild(rowLbl('Annual Taxes:', taxI, 'HOA/Condo Fees (monthly):', hoaI)); 12612 var cdI=field('closingDate','','date'); cdI.style.maxWidth='170px'; 12613 sheet.appendChild(rowLbl('Est. Closing Date:', cdI)); 12614 12615 // Scenario table 12616 var tbl=document.createElement('table'); tbl.className='ds-t'; 12617 var comp=dsCompute(sh); 12618 function si(idx, key, w){ var i=document.createElement('input'); i.className='dsi'; i.type='text'; i.value=(d.scenarios[idx][key]!=null?d.scenarios[idx][key]:''); i.style.width=(w||90)+'px'; i.style.textAlign='center'; 12619 i.addEventListener('input',function(){ d.scenarios[idx][key]=i.value; dsRefresh(sh); }); return i; } 12620 function cid(idx,f){ return 'ds_c'+idx+'_'+f; } 12621 function computedTd(idx,f){ var td=document.createElement('td'); td.className='c'; td.id=cid(idx,f); return td; } 12622 function inputTd(el){ var td=document.createElement('td'); td.className='c'; td.appendChild(el); return td; } 12623 function trLabel(lbl){ var tr=document.createElement('tr'); var td=document.createElement('td'); td.className='lbl'; td.textContent=lbl; tr.appendChild(td); return tr; } 12624 // header 12625 var thr=document.createElement('tr'); thr.innerHTML='<td></td><td class="ds-scenhdr">SCENARIO 1</td><td class="ds-scenhdr">SCENARIO 2</td>'; tbl.appendChild(thr); 12626 function inputRow(lbl,key,w){ var tr=trLabel(lbl); tr.appendChild(inputTd(si(0,key,w))); tr.appendChild(inputTd(si(1,key,w))); tbl.appendChild(tr); } 12627 function calcRow(lbl,f){ var tr=trLabel(lbl); tr.appendChild(computedTd(0,f)); tr.appendChild(computedTd(1,f)); tbl.appendChild(tr); } 12628 inputRow('Sales Price','salesPrice',110); 12629 inputRow('1st Mortgage Payoff','mtg1'); 12630 inputRow('2nd Mortgage Payoff','mtg2'); 12631 inputRow('Other Liens','otherLiens'); 12632 // commission row: % input + computed $ 12633 (function(){ var tr=trLabel('Listing Commission Fees'); 12634 function cell(idx){ var td=document.createElement('td'); td.className='c'; var pi=si(idx,'commissionPct',46); pi.style.width='46px'; 12635 var span=document.createElement('span'); span.id=cid(idx,'commission'); span.style.marginLeft='6px'; 12636 td.appendChild(pi); td.appendChild(document.createTextNode('% ')); td.appendChild(span); return td; } 12637 tr.appendChild(cell(0)); tr.appendChild(cell(1)); tbl.appendChild(tr); })(); 12638 inputRow('Other Fees','otherFees'); 12639 calcRow('Prorated Taxes*','proTax'); 12640 calcRow('Prorated HOA/Condo Fees*','proHOA'); 12641 calcRow('Settlement/Closing Fee','settlement'); 12642 calcRow("Homeowner's Title Policy**",'title'); 12643 calcRow('Reconveyance Fee***','recon'); 12644 inputRow('Home Warranty','homeWarranty'); 12645 inputRow('Utilities/Assessments','utilities'); 12646 inputRow('Seller Concessions','concessions'); 12647 // Other - custom label 12648 (function(){ var tr=document.createElement('tr'); var td=document.createElement('td'); td.className='lbl'; 12649 var otherLbl=document.createElement('input'); otherLbl.className='dsi'; otherLbl.value=d.scenarios[0].otherLabel||'Other'; otherLbl.style.width='150px'; 12650 otherLbl.addEventListener('input',function(){ d.scenarios[0].otherLabel=otherLbl.value; d.scenarios[1].otherLabel=otherLbl.value; }); 12651 td.appendChild(document.createTextNode('Other - ')); td.appendChild(otherLbl); tr.appendChild(td); 12652 tr.appendChild(inputTd(si(0,'otherAmount'))); tr.appendChild(inputTd(si(1,'otherAmount'))); tbl.appendChild(tr); })(); 12653 // net 12654 var ntr=document.createElement('tr'); ntr.className='ds-net'; var ntd=document.createElement('td'); ntd.className='lbl'; ntd.textContent='Estimated Net to Seller'; ntr.appendChild(ntd); 12655 ntr.appendChild(computedTd(0,'net')); ntr.appendChild(computedTd(1,'net')); tbl.appendChild(ntr); 12656 sheet.appendChild(tbl); 12657 sheet.appendChild(mkDivSafe('','<div class="ds-foot">*Prorated taxes and/or HOA/Condo fees are assumed to be the sellerâs responsibility unless otherwise specified.<br>**Title Policy is an Ownerâs Policy based on the sales price.<br>*** Recon fees vary based on the actual number of liens to be released. Properties with no liens will not be charged.</div>')); 12658 root.appendChild(sheet); 12659 dsRefresh(sh); 12660} 12661function dsRefresh(sh){ 12662 var comp=dsCompute(sh); 12663 comp.forEach(function(r,idx){ 12664 function set(f,val){ var el=ge('ds_c'+idx+'_'+f); if(el) el.textContent = (r.price>0? val : ''); } 12665 set('proTax',dsM(r.proTax)); set('proHOA',dsM(r.proHOA)); set('settlement',dsM(r.settlement)); 12666 set('title',dsM(r.title)); set('recon',dsM(r.recon)); set('secure',dsM(r.secure)); 12667 var cs=ge('ds_c'+idx+'_commission'); if(cs) cs.textContent = r.price>0 ? dsM(r.commission) : ''; 12668 var nt=ge('ds_c'+idx+'_net'); if(nt) nt.textContent = r.price>0 ? dsM(r.net) : ''; 12669 }); 12670} 12671function dsLoadScript(src){ return new Promise(function(res,rej){ if(document.querySelector('script[src="'+src+'"]')){ res(); return; } var s=document.createElement('script'); s.src=src; s.onload=res; s.onerror=rej; document.head.appendChild(s); }); } 12672// Render the sheet to a canvas at a FIXED desktop width (800px) regardless of the device, so a 12673// phone export isn't captured at the narrow mobile viewport width (which cut off the form). 12674function dsRenderCanvas(){ 12675 return dsLoadScript('https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js').then(function(){ 12676 var node=ge('dsSheet'); var pw=node.style.width, pm=node.style.maxWidth; 12677 node.style.width='800px'; node.style.maxWidth='800px'; 12678 var restore=function(){ node.style.width=pw; node.style.maxWidth=pm; }; 12679 return window.html2canvas(node,{scale:2,backgroundColor:'#ffffff',useCORS:true,windowWidth:840,width:800}
12679).then(function(c){ restore(); return c; }, function(e){ restore(); throw e; }); 12680 }); 12681} 12682function dsExportPDF(sh, btn){ 12683 if(btn){ btn.textContent='Buildingâ¦'; btn.disabled=true; } 12684 dsLoadScript('https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js') 12685 .then(dsRenderCanvas) 12686 .then(function(canvas){ 12687 var img=canvas.toDataURL('image/png'); 12688 var JsPDF=(window.jspdf&&window.jspdf.jsPDF)||window.jsPDF; 12689 var pdf=new JsPDF('p','pt','letter'); var pw=612, ph=792, m=24; 12690 var iw=pw-m*2, ih=canvas.height*(iw/canvas.width); 12691 if(ih>ph-m*2){ ih=ph-m*2; iw=canvas.width*(ih/canvas.height); } 12692 pdf.addImage(img,'PNG',(pw-iw)/2,m,iw,ih); 12693 pdf.save((sh.name||'Seller Net Sheet').replace(/[^a-z0-9]+/gi,'_')+'.pdf'); 12694 if(btn){ btn.textContent='Export PDF'; btn.disabled=false; } 12695 }).catch(function(e){ if(btn){ btn.textContent='Export PDF'; btn.disabled=false; } alert('PDF export failed: '+(e&&e.message||e)); }); 12696} 12697function dsExportPNG(sh, btn){ 12698 if(btn){ btn.textContent='Buildingâ¦'; btn.disabled=true; } 12699 dsRenderCanvas().then(function(canvas){ 12700 var a=document.createElement('a'); a.href=canvas.toDataURL('image/png'); 12701 a.download=(sh.name||'Seller Net Sheet').replace(/[^a-z0-9]+/gi,'_')+'.png'; a.click(); 12702 if(btn){ btn.textContent='Save PNG'; btn.disabled=false; } 12703 }).catch(function(e){ if(btn){ btn.textContent='Save PNG'; btn.disabled=false; } alert('PNG export failed: '+(e&&e.message||e)); }); 12704} 12705// ============================================================================ 12706// COMMERCIAL SEARCH â tenant/buyer-rep prospecting. A shortlist of candidate commercial properties 12707// scouted for a client: capture fast in the field, enrich with due diligence later (zoning, NNN/CAM, 12708// $/SF/yr...), track through a status pipeline, and export a client-facing PDF. Stored in CPROS. 12709// ============================================================================ 12710var CPRO_STATUS=[['spotted','Spotted'],['researching','Researching'],['shortlisted','Shortlisted'],['presented','Presented'],['toured','Toured'],['passed','Passed']]; 12711var CPRO_DEALTYPES=[['lease','For lease'],['sale','For sale'],['both','Lease or sale']]; 12712var CPRO_LEASETYPES=[['NNN','NNN (triple net)'],['modified','Modified gross'],['gross','Full gross']]; 12713var _cproView='active', _cproClient='', _cproQuery=''; 12714var _cproOpen={}; // per-client collapse state for the grouped Property Search list (true = expanded) 12715function cproStatusLabel(v){ for(var i=0;i<CPRO_STATUS.length;i++){ if(CPRO_STATUS[i][0]===v) return CPRO_STATUS[i][1]; } return v||''; } 12716function cproStatusColor(v){ return ({spotted:'#888',researching:'#7a5cff',shortlisted:'#1f9d55',presented:'#d08b1f',toured:'#2b8fc0',passed:'#c0392b'})[v]||'#888'; } 12717function cproArchived(p){ return !!(p.data&&p.data.archived); } 12718function cproM(n){ n=dsNum(n); return n?('$'+n.toLocaleString('en-US',{maximumFractionDigits:0})):''; } 12719function cproPerSf(n){ n=dsNum(n); return n?('$'+n.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})):''; } 12720function prospectDefault(){ return { id:Date.now()+Math.floor(Math.random()*100000), contact_id:null, name:'', data:{ 12721 status:'spotted', address_full:'', city:'', state:'UT', zip:'', 12722 deal_type:'lease', use_type:'', zoning:'', rentable_sqft:'', lot_size:'', year_built:'', parking:'', 12723 lease_type:'NNN', rate_per_sf:'', cam_per_sf:'', price:'', availability:'', 12724 listing_url:'', listing_source:'', agent:'', pros:'', cons:'', notes:'', lat:'', lon:'', photos:[], comms:[], archived:false } }; } 12725// Manual communication log for a prospect: each entry is { when, who, result }. Rendered as editable 12726// rows with a "+" to add another and an "Ã" to remove. Internal only (not on the client PDF). 12727function cproCommsBlock(d, onChange){ 12728 if(!Array.isArray(d.comms)) d.comms=[]; 12729 function nowLocal(){ var dt=new Date(); dt.setMinutes(dt.getMinutes()-dt.getTimezoneOffset()); return dt.toISOString().slice(0,16); } 12730 var wrap=document.createElement('div'); wrap.className='c-wide'; 12731 var lab=document.createElement('label'); lab.textContent='Communication log'; wrap.appendChild(lab); 12732 var tbl=document.createElement('div'); tbl.style.cssText='display:flex;flex-direction:column;gap:6px;margin-top:4px;'; wrap.appendChild(tbl); 12733 function redraw(){ 12734 tbl.innerHTML='';
12735 if(!d.comms.length){ 12736 var add0=document.createElement('button'); add0.type='button'; add0.className='cbtn g'; add0.style.cssText='font-size:12px;padding:6px 12px;align-self:flex-start;'; add0.textContent='+ Log a contact'; 12737 add0.addEventListener('click',function(){ d.comms.push({ when:nowLocal(), who:'', result:'' }); redraw(); if(onChange)onChange(); }); 12738 tbl.appendChild(add0); return; 12739 } 12740 var hd=document.createElement('div'); hd.style.cssText='display:flex;gap:6px;font-size:11px;color:var(--text3);'; 12741 hd.innerHTML='<div style="flex:0 0 172px;">When</div><div style="flex:1;">With whom</div><div style="flex:1.5;">Result / notes</div><div style="flex:0 0 60px;"></div>'; 12742 tbl.appendChild(hd); 12743 d.comms.forEach(function(entry,idx){ 12744 var r=document.createElement('div'); r.style.cssText='display:flex;gap:6px;align-items:center;'; 12745 function inp(val,ph,flex,type){ var i=document.createElement('input'); i.type=type||'text'; i.value=val||''; if(ph)i.placeholder=ph; i.style.flex=flex; return i; } 12746 var when=inp(entry.when,'','0 0 172px','datetime-local'); 12747 when.addEventListener('input',function(){ entry.when=when.value; if(onChange)onChange(); }); 12748 var who=inp(entry.who,'Name / role','1'); who.addEventListener('input',function(){ entry.who=who.value; if(onChange)onChange(); }); 12749 var res=inp(entry.result,'e.g. Left VM · emailed info · spoke, wants tour','1.5'); res.addEventListener('input',function(){ entry.result=res.value; if(onChange)onChange(); }); 12750 var add=document.createElement('button'); add.type='button'; add.className='cbtn'; add.textContent='+'; add.title='Add a row'; add.style.cssText='flex:0 0 28px;padding:3px 0;font-size:16px;line-height:1;'; 12751 add.addEventListener('click',function(){ d.comms.splice(idx+1,0,{ when:nowLocal(), who:'', result:'' }); redraw(); if(onChange)onChange(); }); 12752 var rm=document.createElement('button'); rm.type='button'; rm.className='cbtn g'; rm.textContent='Ã'; rm.title='Remove'; rm.style.cssText='flex:0 0 28px;padding:3px 0;font-size:16px;line-height:1;'; 12753 rm.addEventListener('click',function(){ d.comms.splice(idx,1); redraw(); if(onChange)onChange(); }); 12754 r.appendChild(when); r.appendChild(who); r.appendChild(res); r.appendChild(add); r.appendChild(rm); 12755 tbl.appendChild(r); 12756 }); 12757 } 12758 redraw(); return wrap; 12759} 12760// The most recent logged contact date (YYYY-MM-DD) for a prospect, or '' if none. 12761function cproLastContact(d){ var latest=''; (Array.isArray(d&&d.comms)?d.comms:[]).forEach(function(x){ if(x&&x.when&&x.when>latest) latest=x.when; }); return latest?latest.slice(0,10):''; } 12762// Read a photo from the camera/gallery, downscale it (longest side 1400px) and return a compressed JPEG 12763// data URL so it fits comfortably inside the prospect's JSON and syncs like any other field. 12764function cproCompressImage(file, cb){ 12765 try{ 12766 var reader=new FileReader(); 12767 reader.onload=function(e){ var img=new Image(); 12768 img.onload=function(){ var max=1400, w=img.width, h=img.height; 12769 if(w>max||h>max){ if(w>=h){ h=Math.round(h*max/w); w=max; } else { w=Math.round(w*max/h); h=max; } } 12770 var cv=document.createElement('canvas'); cv.width=w; cv.height=h; 12771 try{ cv.getContext('2d').drawImage(img,0,0,w,h); cb(cv.toDataURL('image/jpeg',0.72)); } 12772 catch(err){ cb(e.target.result); } }; 12773 img.onerror=function(){ cb(null); }; img.src=e.target.result; }; 12774 reader.onerror=function(){ cb(null); }; reader.readAsDataURL(file); 12775 }catch(err){ cb(null); } 12776} 12777// Save a photo (a data URL) to the device as a JPEG so it can be attached to an email or text. 12778function cproDownloadPhoto(dataUrl, filename){ 12779 try{ var a=document.createElement('a'); a.href=dataUrl; a.download=filename||'photo.jpg'; document.body.appendChild(a); a.click(); setTimeout(function(){ try{ document.body.removeChild(a); }catch(e){} },150); } 12780 catch(e){ alert('Could not start the download.'); } 12781} 12782function cproPhotoName(d, idx){ var base=String((d&&d.address_full)||'property').replace(/[^a-z0-9]+/gi,'_').replace(/^_+|_+$/g,'').slice(0,50)||'property'; return base+'_photo'+((idx||0)+1)+'.jpg'; } 12783// Full-screen photo viewer with a Download button (tap a thumbnail). It registers a history entry when 12784// it opens, so the phone/browser Back button just closes the photo and returns you to the property 12785// (instead of navigating the whole app back to the dashboard or exiting). 12786function cproViewPhoto(src, filename){ 12787 var ov=document.createElement('div'); ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.88);z-index:2000;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;padding:16px;'; 12788 var closeBtn=document.createElement('button'); closeBtn.textContent='â'; closeBtn.style.cssText='position:absolute;top:14px;right:16px;background:rgba(0,0,0,.45);color:#fff;border:none;border-radius:50%;width:40px;height:40px;font-size:20px;line-height:1;cursor:pointer;'; 12789 var im=document.createElement('img'); im.src=src; im.style.cssText='max-width:96vw;max-height:80vh;border-radius:8px;'; 12790 im.addEventListener('click',function(e){ e.stopPropagation(); }); 12791 var dl=document.createElement('button'); dl.textContent='⤠Download photo'; dl.style.cssText='background:var(--accent,#b8912f);color:#fff;border:none;border-radius:8px;padding:11px 20px;font-family:inherit;font-size:15px;cursor:pointer;'; 12792 dl.addEventListener('click',function(e){ e.stopPropagation(); cproDownloadPhoto(src, filename||'photo.jpg'); }); 12793 var hint=document.createElement('div'); hint.textContent='Tap outside, â, or Back to close'; hint.style.cssText='color:#bbb;font-size:12px;'; 12794 ov.appendChild(closeBtn); ov.appendChild(im); ov.appendChild(dl); ov.appendChild(hint); 12795 var closed=false; 12796 function teardown(){ if(closed) return; closed=true; window.removeEventListener('popstate', onPop); try{ document.body.removeChild(ov); }catch(e){} } 12797 function onPop(){ teardown(); } // hardware/browser Back -> consume our entry and just close 12798 function dismiss(){ if(closed) return; if(history.state && history.state.cproPhoto){ history.back(); } else { teardown(); } } 12799 closeBtn.addEventListener('click',function(e){ e.stopPropagation(); dismiss(); }); 12800 ov.addEventListener('click', dismiss); 12801 window.addEventListener('popstate', onPop); 12802 try{ history.pushState({ cproPhoto:true }, ''); }catch(e){} 12803 document.body.appendChild(ov); 12804} 12805// Reusable "Photos" block: thumbnails with remove buttons + an "Add photo" control (camera on mobile). 12806function cproPhotoBlock(d, onChange){ 12807 if(!Array.isArray(d.photos)) d.photos=[]; 12808 var wrap=document.createElement('div'); wrap.className='c-wide';
12809 var lab=document.createElement('label'); lab.textContent='Photos'; wrap.appendChild(lab); 12810 var strip=document.createElement('div'); strip.style.cssText='display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:4px;'; 12811 function redraw(){ 12812 strip.innerHTML=''; 12813 d.photos.forEach(function(src,idx){ 12814 var cell=document.createElement('div'); cell.style.cssText='position:relative;'; 12815 var im=document.createElement('img'); im.src=src; im.style.cssText='width:84px;height:84px;object-fit:cover;border-radius:8px;border:1px solid var(--border);cursor:pointer;'; 12816 im.addEventListener('click',function(){ cproViewPhoto(src, cproPhotoName(d, idx)); }); 12817 var rm=document.createElement('button'); rm.type='button'; rm.textContent='Ã'; rm.title='Remove'; rm.style.cssText='position:absolute;top:-6px;right:-6px;width:20px;height:20px;border-radius:50%;border:none;background:#c0392b;color:#fff;font-size:13px;line-height:1;cursor:pointer;'; 12818 rm.addEventListener('click',function(e){ e.stopPropagation(); d.photos.splice(idx,1); redraw(); if(onChange)onChange(); }); 12819 var dl=document.createElement('button'); dl.type='button'; dl.textContent='â¤'; dl.title='Download'; dl.style.cssText='position:absolute;bottom:-6px;right:-6px;width:22px;height:22px;border-radius:50%;border:none;background:var(--accent,#b8912f);color:#fff;font-size:12px;line-height:1;cursor:pointer;'; 12820 (function(s,i){ dl.addEventListener('click',function(e){ e.stopPropagation(); cproDownloadPhoto(s, cproPhotoName(d, i)); }); })(src, idx); 12821 cell.appendChild(im); cell.appendChild(rm); cell.appendChild(dl); strip.appendChild(cell); 12822 }); 12823 var add=document.createElement('button'); add.type='button'; add.className='cbtn g'; add.textContent='ð· Add photo'; add.style.cssText='font-size:12px;padding:6px 12px;height:84px;'; 12824 var inp=document.createElement('input'); inp.type='file'; inp.accept='image/*'; inp.setAttribute('capture','environment'); inp.style.display='none'; 12825 add.addEventListener('click',function(){ inp.click(); }); 12826 inp.addEventListener('change',function(){ var f=inp.files&&inp.files[0]; if(!f){ return; } add.textContent='â¦'; cproCompressImage(f,function(url){ add.textContent='ð· Add photo'; inp.value=''; if(url){ d.photos.push(url); redraw(); if(onChange)onChange(); } else { alert('Could not read that image.'); } }); }); 12827 strip.appendChild(add); strip.appendChild(inp); 12828 } 12829 redraw(); wrap.appendChild(strip); return wrap; 12830} 12831// Use the phone's GPS to fill an address (reverse-geocoded via /api/geocode). onResult(result, lat, lon). 12832function cproGeolocate(btn, onResult){ 12833 if(!navigator.geolocation){ alert('This device canât share its location.'); return; } 12834 var orig=btn?btn.textContent:''; if(btn){ btn.textContent='Locatingâ¦'; btn.disabled=true; } 12835 function stop(){ if(btn){ btn.textContent=orig; btn.disabled=false; } } 12836 navigator.geolocation.getCurrentPosition(function(pos){ 12837 var lat=pos.coords.latitude, lon=pos.coords.longitude; 12838 (async function(){
12839 try{ 12840 var resp=await fetch('/api/geocode?lat='+encodeURIComponent(lat)+'&lon='+encodeURIComponent(lon),{ headers: await apiHeaders() }); 12841 var j=await resp.json(); if(!resp.ok || !j || j.error) throw new Error((j&&j.error)||('HTTP '+resp.status)); 12842 stop(); onResult(j, lat, lon); 12843 }catch(e){ stop(); alert('Got your location, but the address lookup failed: '+(e&&e.message||e)+'\nYou can type the address instead.'); } 12844 })(); 12845 }, function(err){ stop(); alert(err&&err.code===1 ? 'Location permission was denied. Allow location for this site, or type the address.' : 'Could not get your location. Type the address instead.'); }, 12846 { enableHighAccuracy:true, timeout:12000, maximumAge:0 }); 12847} 12848function cproCompute(d){ var sf=dsNum(d.rentable_sqft), rate=dsNum(d.rate_per_sf), cam=dsNum(d.cam_per_sf); 12849 return { sf:sf, rate:rate, cam:cam, annualBase:rate*sf, moBase:rate*sf/12, moCam:cam*sf/12, moAll:(rate+cam)*sf/12, annualAll:(rate+cam)*sf, hasSf:sf>0 }; } 12850function cproSpecLine(d){ var c=cproCompute(d); 12851 var bits=[]; if(d.use_type) bits.push(d.use_type); if(c.sf) bits.push(Number(c.sf).toLocaleString()+' SF'); if(c.rate) bits.push(cproPerSf(c.rate)+'/SF '+(d.lease_type||'')); return bits.filter(Boolean).join(' · '); } 12852function _cproStyle(){ 12853 if(ge('cproCss')) return; var s=document.createElement('style'); s.id='cproCss'; 12854 s.textContent= 12855 '#page-commercial .cbtn{background:var(--accent);color:#fff;border:none;border-radius:7px;padding:7px 14px;font-family:inherit;font-size:14px;cursor:pointer;}' 12856 +'#page-commercial .cbtn.g{background:transparent;border:1px solid var(--border);color:var(--text);}' 12857 +'#page-commercial .c-form{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:14px;margin-bottom:16px;}' 12858 +'#page-commercial .c-form label{font-size:12px;color:var(--text3);display:block;margin-bottom:3px;}' 12859 +'#page-commercial .c-form input,#page-commercial .c-form textarea,#page-commercial .c-form select{width:100%;box-sizing:border-box;background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-family:inherit;font-size:13px;}' 12860 +'#page-commercial .c-sec{grid-column:1/-1;font-size:13px;font-weight:700;color:var(--text2);margin:6px 0 -2px;border-top:1px solid var(--border);padding-top:10px;}' 12861 +'#page-commercial .c-sec:first-child{border-top:none;padding-top:0;}' 12862 +'#page-commercial .c-wide{grid-column:1/-1;}' 12863 +'#page-commercial .c-badge{display:inline-block;font-size:10px;font-weight:700;color:#fff;border-radius:4px;padding:2px 7px;vertical-align:middle;}'; 12864 document.head.appendChild(s); 12865} 12866function quickAddProspect(){ 12867 var geo={}; // captured lat/lon/city/state/zip from "Use my location" 12868 var ov=document.createElement('div'); ov.className='modal-ov open'; ov.style.zIndex='1300'; 12869 var m=document.createElement('div'); m.className='modal'; m.style.maxWidth='520px'; 12870 var h=document.createElement('div'); h.style.cssText='display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border);'; 12871 h.innerHTML='<div style="font-weight:700;font-size:17px;">Quick add â commercial prospect</div>'; 12872 var x=document.createElement('button'); x.textContent='â'; x.style.cssText='background:none;border:none;font-size:18px;color:var(--text3);cursor:pointer;'; x.addEventListener('click',function(){ document.body.removeChild(ov); }); h.appendChild(x); m.appendChild(h); 12873 var body=document.createElement('div'); body.style.cssText='padding:16px 20px;display:flex;flex-direction:column;gap:12px;'; 12874 function lbl(t){ var l=document.createElement('label'); l.className='fl'; l.textContent=t; return l; } 12875 var cw=document.createElement('div'); cw.appendChild(lbl('Client')); 12876 var csel=document.createElement('select'); csel.className='fsel'; 12877 [{value:'',label:'â pick later â'}].concat((C||[]).map(function(c){return {value:String(c.id),label:fn(c)+(c.property?(' · '+c.property):'')};}
12877)).forEach(function(o){ var op=document.createElement('option'); op.value=o.value; op.textContent=o.label; csel.appendChild(op); }); 12878 cw.appendChild(csel); body.appendChild(cw); 12879 var aw=document.createElement('div'); aw.appendChild(lbl('Address')); 12880 var arow=document.createElement('div'); arow.style.cssText='display:flex;gap:8px;'; 12881 var ain=document.createElement('input'); ain.className='fi'; ain.style.flex='1'; ain.placeholder='Type it, or use your location â'; 12882 var gb=document.createElement('button'); gb.className='cbtn'; gb.type='button'; gb.style.cssText='white-space:nowrap;padding:7px 12px;'; gb.textContent='ð Use my location'; 12883 gb.addEventListener('click',function(){ cproGeolocate(gb,function(j,lat,lon){ if(j.address) ain.value=j.address; geo={ city:j.city, state:j.state, zip:j.zip, lat:lat, lon:lon }; }); }); 12884 arow.appendChild(ain); arow.appendChild(gb); aw.appendChild(arow); body.appendChild(aw); 12885 var nw=document.createElement('div'); nw.appendChild(lbl('Quick note (what caught your eye)')); 12886 var nin=document.createElement('textarea'); nin.className='fi'; nin.rows=2; nw.appendChild(nin); body.appendChild(nw); 12887 var qd={photos:[]}; body.appendChild(cproPhotoBlock(qd)); 12888 m.appendChild(body); 12889 var ft=document.createElement('div'); ft.style.cssText='display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--border);'; 12890 var cancel=document.createElement('button'); cancel.className='btn btn-g'; cancel.textContent='Cancel'; cancel.addEventListener('click',function(){ document.body.removeChild(ov); }); 12891 var save=document.createElement('button'); save.className='btn btn-p'; save.textContent='Save'; 12892 save.addEventListener('click',function(){ 12893 var addr=(ain.value||'').trim(); if(!addr){ alert('Enter an address (or use your location).'); return; } 12894 var p=prospectDefault(); p.contact_id=csel.value?parseInt(csel.value):null; 12895 p.data.address_full=addr; p.data.notes=(nin.value||'').trim(); p.name=addr; 12896 if(qd.photos&&qd.photos.length) p.data.photos=qd.photos; 12897 if(geo.city) p.data.city=geo.city; if(geo.state) p.data.state=geo.state; if(geo.zip) p.data.zip=geo.zip; 12898 if(geo.lat!=null) p.data.lat=geo.lat; if(geo.lon!=null) p.data.lon=geo.lon; 12899 CPROS.push(p); saveProspect(p); document.body.removeChild(ov); renderCommercial(); 12900 }); 12901 ft.appendChild(cancel); ft.appendChild(save); m.appendChild(ft); 12902 ov.appendChild(m); ov.addEventListener('click',function(e){ if(e.target===ov) document.body.removeChild(ov); }); 12903 document.body.appendChild(ov); setTimeout(function(){ try{ ain.focus(); }catch(e){} },50); 12904} 12905function renderCommercial(){ 12906 _cproStyle(); var root=ge('cproRoot'); if(!root) return; root.innerHTML=''; 12907 var bar=document.createElement('div'); bar.style.cssText='display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;'; 12908 bar.appendChild(mkDivSafe('font-size:22px;font-weight:600;','Property Search')); 12909 var bwrap=document.createElement('div'); bwrap.style.cssText='display:flex;gap:8px;flex-wrap:wrap;'; 12910 var qa=document.createElement('button'); qa.className='cbtn'; qa.textContent='+ Quick add'; qa.addEventListener('click',quickAddProspect); bwrap.appendChild(qa); 12911 var nf=document.createElement('button'); nf.className='cbtn g'; nf.textContent='New (full)'; nf.addEventListener('click',function(){ openProspect(prospectDefault(), true); }); bwrap.appendChild(nf); 12912 bar.appendChild(bwrap); root.appendChild(bar); 12913 root.appendChild(mkDivSafe('color:var(--text3);font-size:12px;margin:-2px 0 12px;','Track candidate commercial properties per client. Capture the address fast now; add zoning, NNN/CAM, $/SF and the rest later; export a clientâs shortlist as a PDF.')); 12914 if(!CPROS.length){ root.appendChild(mkDivSafe('color:var(--text3);padding:20px 0;','No prospects yet. Use Quick add while youâre out, or New (full) to enter everything.')); return; } 12915 var ctr=document.createElement('div'); ctr.style.cssText='display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px;'; 12916 var search=document.createElement('input'); search.type='text'; search.placeholder='Search address / use / zoningâ¦'; search.value=_cproQuery; 12917 search.style.cssText='flex:1;min-width:180px;background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:7px 10px;font-family:inherit;font-size:13px;'; 12918 var cli=document.createElement('select'); cli.style.cssText='background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:7px 8px;font-family:inherit;font-size:13px;width:auto;'; 12919 var cliIds={};
12919 CPROS.forEach(function(p){ if(p.contact_id!=null) cliIds[p.contact_id]=true; }); 12920 var cOpts=[['','All clients']].concat(Object.keys(cliIds).map(function(id){ var c=gc(parseInt(id)); return [String(id), c?fn(c):('Client '+id)]; })); 12921 cOpts.forEach(function(o){ var op=document.createElement('option'); op.value=o[0]; op.textContent=o[1]; if(o[0]===_cproClient) op.selected=true; cli.appendChild(op); }); 12922 var stat=document.createElement('select'); stat.style.cssText=cli.style.cssText; 12923 [['active','Active']].concat(CPRO_STATUS).concat([['archived','Archived']]).forEach(function(o){ var op=document.createElement('option'); op.value=o[0]; op.textContent=o[1]; if(o[0]===_cproView) op.selected=true; stat.appendChild(op); }); 12924 ctr.appendChild(search); ctr.appendChild(cli); ctr.appendChild(stat); 12925 var expB=document.createElement('button'); expB.className='cbtn'; expB.style.cssText='padding:7px 12px;'; expB.textContent='Export client PDF'; 12926 expB.addEventListener('click',function(){ if(!_cproClient){ alert('Pick a client in the filter first, then export their shortlist.'); return; } cproExportPdf(parseInt(_cproClient), expB); }); 12927 ctr.appendChild(expB); root.appendChild(ctr); 12928 var list=document.createElement('div'); list.style.cssText='display:flex;flex-direction:column;gap:8px;'; root.appendChild(list); 12929 function rowEl(p){ 12930 var d=p.data||{}, arch=cproArchived(p), c=gc(p.contact_id); 12931 var passed=(d.status==='passed'); // "won't work" â kept visible but depreciated so we don't re-scout it 12932 var row=document.createElement('div'); row.style.cssText='display:flex;justify-content:space-between;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:10px 14px;margin-left:6px;cursor:pointer;'+((arch||passed)?'opacity:.6;':''); 12933 var badge='<span class="c-badge" style="background:'+cproStatusColor(d.status)+';">'+_esc(cproStatusLabel(d.status||'spotted'))+'</span>'; 12934 var _lc=cproLastContact(d); 12935 var sub=[ cproSpecLine(d), (_lc?('â '+_lc):'') ].filter(Boolean).join(' · '); 12936 var thumb=(Array.isArray(d.photos)&&d.photos.length)?'<img src="'+d.photos[0]+'" style="width:46px;height:46px;object-fit:cover;border-radius:6px;border:1px solid var(--border);flex-shrink:0;'+(passed?'filter:grayscale(1);':'')+'">':''; 12937 var addrStyle='font-weight:600;overflow:hidden;text-overflow:ellipsis;'+(passed?'text-decoration:line-through;color:var(--text3);':''); 12938 row.innerHTML='<div style="display:flex;align-items:center;gap:10px;min-width:0;">'+thumb+'<div style="min-width:0;"><div style="'+addrStyle+'">'+_esc(d.address_full||p.name||'(no address)')+' '+badge+(arch?' <span class="c-badge" style="background:#888;">ARCHIVED</span>':'')+'</div>'+(sub?('<div style="font-size:12px;color:var(--text3);">'+_esc(sub)+'</div>'):'')+'</div></div>'; 12939 var btns=document.createElement('div'); btns.style.cssText='display:flex;gap:6px;flex-shrink:0;'; 12940 var pb=document.createElement('button'); pb.className='cbtn g'; pb.style.cssText='font-size:12px;padding:3px 9px;'+(passed?'':'color:#c0392b;'); pb.textContent=passed?'Reactivate':'Wonât work'; 12941 pb.addEventListener('click',function(e){ e.stopPropagation(); if(!p.data)p.data={}; p.data.status=passed?'spotted':'passed'; saveProspect(p); draw(); }); 12942 var ab=document.createElement('button'); ab.className='cbtn g'; ab.style.cssText='font-size:12px;padding:3px 9px;'; ab.textContent=arch?'Unarchive':'Archive'; 12943 ab.addEventListener('click',function(e){ e.stopPropagation(); if(!p.data)p.data={}; p.data.archived=!arch; saveProspect(p); draw(); }); 12944 var del=document.createElement('button'); del.className='cbtn g'; del.style.cssText='font-size:12px;padding:3px 9px;'; del.textContent='Delete'; 12945 (function(id){ del.addEventListener('click',function(e){ e.stopPropagation(); if(confirm('Delete this prospect?')){ delProspect(id); draw(); } }); })(p.id); 12946 btns.appendChild(pb); btns.appendChild(ab); btns.appendChild(del); 12947 row.addEventListener('click',function(){ openProspect(p,false); }); row.appendChild(btns); return row; 12948 } 12949 function draw(){ 12950 list.innerHTML=''; var q=_cproQuery.trim().toLowerCase(); 12951 var items=CPROS.filter(function(p){ 12952 var d=p.data||{}, arch=cproArchived(p); 12953 if(_cproClient && String(p.contact_id)!==String(_cproClient)) return false;
12954 if(q){ var hay=((d.address_full||'')+' '+(d.use_type||'')+' '+(d.zoning||'')+' '+(d.city||'')).toLowerCase(); if(hay.indexOf(q)<0) return false; } 12955 if(_cproView==='archived') return arch; 12956 if(arch) return false; 12957 if(_cproView==='active') return true; 12958 return d.status===_cproView; 12959 }).sort(function(a,b){ 12960 var pa=(a.data&&a.data.status==='passed')?1:0, pb=(b.data&&b.data.status==='passed')?1:0; 12961 if(pa!==pb) return pa-pb; // "won't work" sink to the bottom, active stay on top 12962 return String(b.updated_at||'').localeCompare(String(a.updated_at||'')); 12963 }); 12964 if(!items.length){ list.appendChild(mkDivSafe('color:var(--text3);padding:16px 0;','Nothing matches.')); return; } 12965 // Group by client, each under a collapsible header so a book of many properties stays navigable. 12966 var groups={}, order=[]; 12967 items.forEach(function(p){ 12968 var key=(p.contact_id!=null && gc(p.contact_id)) ? String(p.contact_id) : '__none__'; 12969 if(!groups[key]){ groups[key]=[]; order.push(key); } 12970 groups[key].push(p); 12971 }); 12972 function clientName(key){ if(key==='__none__') return '(No client assigned)'; var c=gc(parseInt(key)); return c?fn(c):'(Unknown client)'; } 12973 order.sort(function(a,b){ if(a==='__none__') return 1; if(b==='__none__') return -1; return clientName(a).toLowerCase().localeCompare(clientName(b).toLowerCase()); }); 12974 var searching=!!q; 12975 order.forEach(function(key){ 12976 var arr=groups[key]; 12977 // Expanded when: a single client is filtered, only one group exists, the user is searching, or the 12978 // user has clicked this header open. Otherwise collapsed so the client list stays scannable. 12979 var open = _cproClient ? true : (order.length===1 || searching || !!_cproOpen[key]); 12980 var hdr=document.createElement('div'); hdr.style.cssText='display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;background:var(--surface3);border:1px solid var(--border);border-radius:7px;padding:9px 12px;margin-top:10px;'; 12981 hdr.innerHTML='<span style="font-weight:700;font-size:15px;">'+_esc(clientName(key))+' <span style="color:var(--text3);font-weight:400;font-size:13px;">('+arr.length+')</span></span> <span style="margin-left:auto;opacity:.6;font-size:12px;">'+(open?'â¾':'â¸')+'</span>'; 12982 (function(k){ hdr.addEventListener('click',function(){ _cproOpen[k]=!open; draw(); }); })(key); 12983 list.appendChild(hdr); 12984 if(open){ arr.forEach(function(p){ list.appendChild(rowEl(p)); }); } 12985 }); 12986 } 12987 search.addEventListener('input',function(){ _cproQuery=search.value; draw(); }); 12988 cli.addEventListener('change',function(){ _cproClient=cli.value; draw(); }); 12989 stat.addEventListener('change',function(){ _cproView=stat.value; draw(); }); 12990 draw(); 12991} 12992function openProspect(p, isNew){ 12993 _cproStyle(); var root=ge('cproRoot'); if(!root) return; root.innerHTML=''; var d=p.data; 12994 var tb=document.createElement('div'); tb.style.cssText='display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px;'; 12995 var back=document.createElement('button'); back.className='cbtn g'; back.textContent='â¹ Back'; back.addEventListener('click',renderCommercial); tb.appendChild(back); 12996 var cWrap=document.createElement('div'); cWrap.style.cssText='min-width:240px;'; tb.appendChild(cWrap); 12997 var clientLine=document.createElement('div'); clientLine.style.cssText='color:var(--text3);font-size:12px;margin:-6px 0 12px;'; 12998 function renderClientLine(){ if(p.contact_id!=null){ var cc=gc(p.contact_id); clientLine.textContent=cc?('Client: '+fn(cc)):''; clientLine.style.display=cc?'':'none'; } else { clientLine.style.display='none'; } } 12999 // Pick a client without rebuilding the whole form: set it, show it, and save it in place. 13000 var picker=buildContactPicker(cWrap,'cpro_client','Client (search name / address)â¦',function(c){ p.contact_id = c ? parseInt(c.id) : null; renderClientLine(); if(CPROS.some(function(x){return String(x.id)===String(p.id);})) saveProspect(p); }); 13001 if(p.contact_id!=null){ var _cc0=gc(p.contact_id); if(_cc0){ picker.input.value=fn(_cc0)+(_cc0.email?(' ('+_cc0.email+')'):''); picker.hidden.value=String(_cc0.id); } } 13002 var saveB=document.createElement('button'); saveB.className='cbtn'; saveB.textContent='Save'; saveB.addEventListener('click',function(){ if(!p.name) p.name=d.address_full||'Prospect'; if(!CPROS.some(function(x){return String(x.id)===String(p.id);})) CPROS.push(p); saveProspect(p); saveB.textContent='Saved â'; setTimeout(function(){saveB.textContent='Save';},1500); }); tb.appendChild(saveB); 13003 var pdfB=document.createElement('button'); pdfB.className='cbtn g'; pdfB.textContent='Export client PDF'; pdfB.addEventListener('click',function(){ if(p.contact_id==null){ alert('Link a client first.'); return; } cproExportPdf(p.contact_id, pdfB); }); tb.appendChild(pdfB); 13004 root.appendChild(tb); 13005 root.appendChild(clientLine); renderClientLine(); 13006 13007 var prev=document.createElement('div'); prev.style.cssText='background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:16px;'; 13008 var form=document.createElement('div'); form.className='c-form'; 13009 function refresh(){ 13010 var cc=cproCompute(d); 13011 var occ=''; 13012 if(cc.hasSf && cc.rate){ occ='Base '+cproM(cc.moBase)+'/mo ('+cproM(cc.annualBase)+'/yr)'; if(cc.cam) occ+=' · +CAM '+cproM(cc.moCam)+'/mo · all-in '+cproM(cc.moAll)+'/mo'; } 13013 prev.innerHTML='<div style="display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;align-items:baseline;">' 13014 +'<div style="font-size:18px;font-weight:700;">'+_esc(d.address_full||'(no address)')+'</div>' 13015 +'<span class="c-badge" style="background:'+cproStatusColor(d.status)+';">'+_esc(cproStatusLabel(d.status))+'</span></div>' 13016 +(cproSpecLine(d)?('<div style="margin:6px 0;font-weight:600;">'+_esc(cproSpecLine(d))+'</div>'):'') 13017 +(occ?('<div style="font-size:13px;color:var(--text2);">'+_esc(occ)+'</div>'):'') 13018 +(d.zoning?('<div style="font-size:12px;color:var(--text3);margin-top:4px;">Zoning: '+_esc(d.zoning)+'</div>'):'') 13019 +(d.notes?('<div style="font-size:13px;margin-top:8px;">'+_esc(d.notes).replace(/\n/g,'<br>')+'</div>'):'') 13020 +((Array.isArray(d.photos)&&d.photos.length)?('<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:10px;">'+d.photos.map(function(s){return '<img src="'+s+'" style="width:70px;height:70px;object-fit:cover;border-radius:6px;border:1px solid var(--border);">';}).join('')+'</div>'):''); 13021 } 13022 function sec(t){ var w=document.createElement('div'); w.className='c-sec'; w.textContent=t; form.appendChild(w); } 13023 function fld(key,label,type,wide){ var w=document.createElement('div'); if(wide)w.className='c-wide'; var l=document.createElement('label'); l.textContent=label; w.appendChild(l); 13024 var el=type==='textarea'?document.createElement('textarea'):document.createElement('input'); if(type&&type!=='textarea')el.type=type; if(type==='textarea')el.rows=3; 13025 el.value=(d[key]!=null?d[key]:''); el.addEventListener('input',function(){ d[key]=el.value; refresh(); }); w.appendChild(el); form.appendChild(w); } 13026 function pick(key,label,opts){ var w=document.createElement('div'); var l=document.createElement('label'); l.textContent=label; w.appendChild(l); 13027 var s=document.createElement('select');
13027 opts.forEach(function(o){ var op=document.createElement('option'); op.value=o[0]; op.textContent=o[1]; if(d[key]===o[0])op.selected=true; s.appendChild(op); }); s.value=d[key]; s.addEventListener('change',function(){ d[key]=s.value; refresh(); }); w.appendChild(s); form.appendChild(w); } 13028 sec('Status & location'); 13029 pick('status','Status',CPRO_STATUS); pick('deal_type','Lease or sale',CPRO_DEALTYPES); 13030 fld('address_full','Address','text',true); 13031 (function(){ var w=document.createElement('div'); w.className='c-wide'; var b=document.createElement('button'); b.className='cbtn g'; b.type='button'; b.textContent='ð Use my location'; b.style.cssText='font-size:12px;padding:5px 12px;'; b.addEventListener('click',function(){ cproGeolocate(b,function(j){ if(j.address) d.address_full=j.address; if(j.city) d.city=j.city; if(j.state) d.state=j.state; if(j.zip) d.zip=j.zip; if(j.lat!=null) d.lat=j.lat; if(j.lon!=null) d.lon=j.lon; openProspect(p,isNew); }); }); w.appendChild(b); form.appendChild(w); })(); 13032 fld('city','City'); fld('state','State'); fld('zip','ZIP'); 13033 sec('Photos'); form.appendChild(cproPhotoBlock(d, refresh)); 13034 sec('Communication log'); 13035 var _commsSaveT; // the contact log auto-saves ~1s after an edit, so a logged call is never lost 13036 form.appendChild(cproCommsBlock(d, function(){ refresh(); clearTimeout(_commsSaveT); _commsSaveT=setTimeout(function(){ if(!p.name) p.name=d.address_full||'Prospect'; if(!CPROS.some(function(x){return String(x.id)===String(p.id);})) CPROS.push(p); saveProspect(p); },1000); })); 13037 sec('Property & zoning'); 13038 fld('use_type','Use / property type (retail, office, industrialâ¦)','text',true); 13039 fld('zoning','Zoning'); fld('rentable_sqft','Rentable SF','number'); fld('lot_size','Lot size'); fld('year_built','Year built','number'); fld('parking','Parking'); 13040 sec('Deal terms'); 13041 fld('rate_per_sf','Asking rate ($/SF/yr)','number'); pick('lease_type','Lease type',CPRO_LEASETYPES); 13042 fld('cam_per_sf','CAM ($/SF/yr)','number'); fld('price','Sale price / other ($)','number'); fld('availability','Availability'); 13043 sec('Listing & contacts'); 13044 fld('listing_url','Listing URL','text',true); fld('listing_source','Listing source (LoopNet, CoStar, signâ¦)'); fld('agent','Listing broker / contact'); 13045 sec('Assessment'); 13046 fld('pros','Pros / fit for client','textarea',true); 13047 fld('cons','Cons / concerns','textarea',true); 13048 fld('notes','Notes','textarea',true); 13049 root.appendChild(form); root.appendChild(prev); refresh(); 13050} 13051function cproSheetHTML(c, items){ 13052 var today=new Date().toLocaleDateString('en-US',{year:'numeric',month:'long',day:'numeric'}); 13053 var css='<style>#cproSheet .cp-h1{font-size:22px;font-weight:700;color:#1a1a1a;} #cproSheet .cp-sub{color:#555;font-size:13px;margin-bottom:2px;}' 13054 +'#cproSheet .cp-card{border:1px solid #ddd;border-radius:8px;padding:14px 16px;margin:12px 0;page-break-inside:avoid;}' 13055 +'#cproSheet .cp-addr{font-size:16px;font-weight:700;} #cproSheet .cp-spec{color:#333;font-size:13px;margin:3px 0 6px;}' 13056 +'#cproSheet table.cp-t{width:100%;border-collapse:collapse;font-size:12.5px;} #cproSheet table.cp-t td{padding:2px 8px 2px 0;vertical-align:top;}' 13057 +'#cproSheet table.cp-t td.k{color:#666;width:150px;} #cproSheet .cp-foot{margin-top:20px;border-top:1px solid #e5e5e5;padding-top:8px;color:#999;font-size:10px;text-align:center;}</style>'; 13058 var rows=items.map(function(p){ 13059 var d=p.data||{}, cc=cproCompute(d), lines=[]; 13060 function tr(k,v){ if(v==null||v==='') return; lines.push('<tr><td class="k">'+_esc(k)+'</td><td>'+_esc(v)+'</td></tr>'); } 13061 tr('Status', cproStatusLabel(d.status)); 13062 tr('Use / type', d.use_type); 13063 tr('Zoning', d.zoning); 13064 tr('Size', cc.sf?(Number(cc.sf).toLocaleString()+' SF'):''); 13065 if(cc.rate) tr('Asking rate', cproPerSf(cc.rate)+'/SF/yr '+(d.lease_type||'')); 13066 if(cc.cam) tr('CAM', cproPerSf(cc.cam)+'/SF/yr'); 13067 if(cc.hasSf && cc.rate){ var occ='Base '+cproM(cc.moBase)+'/mo ('+cproM(cc.annualBase)+'/yr)'; if(cc.cam) occ+='; all-in '+cproM(cc.moAll)+'/mo'; tr('Est. occupancy', occ); } 13068 if(dsNum(d.price)) tr('Sale price', cproM(d.price)); 13069 tr('Availability', d.availability); 13070 tr('Parking', d.parking); 13071 tr('Year built', d.year_built); 13072 tr('Listing', d.listing_url || d.listing_source); 13073 if(d.pros) tr('Fit', d.pros); 13074 if(d.cons) tr('Concerns', d.cons); 13075 if(d.notes) tr('Notes', d.notes); 13076 var photoHtml=(Array.isArray(d.photos)&&d.photos.length) 13077 ? ('<div style="margin:6px 0 8px;">'+d.photos.slice(0,2).map(function(s){return '<img src="'+s+'" style="max-width:320px;max-height:220px;border-radius:6px;margin:0 8px 8px 0;border:1px solid #ccc;">';}).join('')+'</div>') 13078 : ''; 13079 return '<div class="cp-card"><div class="cp-addr">'+_esc(d.address_full||'(no address)')+'</div>' 13080 +(cproSpecLine(d)?('<div class="cp-spec">'+_esc(cproSpecLine(d))+'</div>'):'') 13081 +photoHtml 13082 +'<table class="cp-t">'+lines.join('')+'</table></div>'; 13083 }).join(''); 13084 return css 13085 +'<div class="cp-sub">Palacios Baker Real Estate · Utahâs Wise Choice</div>' 13086 +'<div class="cp-h1">Commercial Property Shortlist</div>' 13087 +'<div class="cp-sub">Prepared for '+_esc(fn(c))+' â '+_esc(today)+'</div>' 13088 +rows 13089 +'<div class="cp-foot">Figures are preliminary and subject to verification. Palacios Baker Real Estate.</div>'; 13090} 13091function cproExportPdf(contactId, btn){ 13092 var c=gc(contactId); if(!c){ alert('Pick a client to export.'); return; } 13093 var items=CPROS.filter(function(p){ return String(p.contact_id)===String(contactId) && !cproArchived(p) && (p.data&&p.data.status!=='passed'); }) 13094 .sort(function(a,b){ return String((a.data&&a.data.address_full)||'').localeCompare(String((b.data&&b.data.address_full)||'')); }); 13095 if(!items.length){ alert('No active (non-passed) properties for this client to export yet.'); return; } 13096 var sheet=document.createElement('div'); sheet.id='cproSheet'; sheet.style.cssText='position:fixed;left:-9999px;top:0;background:#fff;
13096color:#1a1a1a;width:760px;padding:32px;font-family:Georgia,\'Times New Roman\',serif;'; 13097 sheet.innerHTML=cproSheetHTML(c, items); document.body.appendChild(sheet); 13098 var name=(fn(c)||'Client').replace(/[^a-z0-9]+/gi,'_')+'_Commercial_Shortlist'; 13099 if(btn){ btn.textContent='Buildingâ¦'; btn.disabled=true; } 13100 var done=function(){ if(btn){ btn.textContent='Export client PDF'; btn.disabled=false; } try{ document.body.removeChild(sheet); }catch(e){} }; 13101 dsLoadScript('https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js').then(function(){ return _docCanvas('cproSheet'); }).then(function(canvas){ 13102 var img=canvas.toDataURL('image/png'); var JsPDF=(window.jspdf&&window.jspdf.jsPDF)||window.jsPDF; 13103 var pdf=new JsPDF('p','pt','letter'); var pw=612, ph=792, m=24; 13104 var iw=pw-m*2, ih=canvas.height*(iw/canvas.width); var pageH=ph-m*2; 13105 if(ih<=pageH){ pdf.addImage(img,'PNG',m,m,iw,ih); } 13106 else { var pages=Math.ceil(ih/pageH); for(var pg=0;pg<pages;pg++){ if(pg>0) pdf.addPage(); pdf.addImage(img,'PNG',m,m-pg*pageH,iw,ih); } } 13107 pdf.save(name+'.pdf'); done(); 13108 }).catch(function(e){ done(); alert('PDF export failed: '+(e&&e.message||e)); }); 13109} 13110// ============================================================================ 13111// NEW CONSTRUCTION â watch builder communities/pricing/incentives. Fed by builder AGENT emails you 13112// subscribe to (parsed by the scanner) or entered by hand. Ethical by design: the data comes from 13113// emails the builders send you, not from scraping their sites. Records live in NC. 13114// ============================================================================ 13115var NC_STATUS=[['active','Active'],['coming_soon','Coming Soon'],['sold_out','Sold Out'],['unknown','Unknown']]; 13116var _ncBuilder='', _ncCity='', _ncView='active', _ncQuery='', _ncType='', _ncSort='city', _ncCollapse={}; 13117function ncType(d){ var h=String((d&&d.home_types)||'').toLowerCase(); if(/condo/.test(h)) return 'condo'; if(/town|twin/.test(h)) return 'townhome'; if(/single|sfr|detached|family/.test(h)) return 'single family'; return ''; } 13118function ncGuessType(name){ if(/\btowns?\b|townhome/i.test(name)) return 'Townhome'; if(/condo/i.test(name)) return 'Condo'; if(/\bvillas?\b/i.test(name)) return 'Townhome'; return 'Single Family'; } 13119function ncPriceNum(d){ var lo=dsNum(d&&d.price_min); if(lo>0) return lo; var m=String((d&&d.price_text)||'').match(/\$\s*([\d.,]+)\s*([KMkm])?/); if(!m) return 0; var n=parseFloat(m[1].replace(/,/g,''))||0; var suf=(m[2]||'').toUpperCase(); if(suf==='M') return Math.round(n*1e6); if(suf==='K') return Math.round(n*1e3); return (n>0 && n<10000)?n*1000:n; } 13120function ncStatusLabel(v){ for(var i=0;i<NC_STATUS.length;i++){ if(NC_STATUS[i][0]===v) return NC_STATUS[i][1]; } return v||''; } 13121function ncStatusColor(v){ return ({active:'#1f9d55',coming_soon:'#7a5cff',sold_out:'#888',unknown:'#888'})[v]||'#888'; } 13122function ncArchived(x){ return !!(x.data&&x.data.archived); } 13123function ncFmt(n){ n=dsNum(n); return n?('$'+n.toLocaleString('en-US',{maximumFractionDigits:0})):''; } 13124function ncPrice(d){ if(d.price_text) return d.price_text; var lo=dsNum(d.price_min), hi=dsNum(d.price_max); if(lo&&hi) return ncFmt(lo)+'â'+ncFmt(hi); if(lo) return 'From '+ncFmt(lo); return ''; } 13125function ncRecent(x){ var t=x.updated_at?Date.parse(x.updated_at):0; return t && (Date.now()-t) < 8*864e5; } 13126function ncDefault(){ return { id:Date.now()+Math.floor(Math.random()*100000), builder:'', name:'', data:{ 13127 city:'', state:'UT', home_types:'', price_text:'', price_min:'', price_max:'', promo:'', status:'active', 13128 url:'', source:'manual', email_ref:'', notes:'', archived:false } }; } 13129function ncKey(builder,name){ return String(builder||'').trim().toLowerCase()+'|'+String(name||'').trim().toLowerCase(); } 13130function ncFind(builder,name){ var k=ncKey(builder,name); for(var i=0;i<NC.length;i++){ if(ncKey(NC[i].builder,NC[i].name)===k) return NC[i]; } return null; } 13131// Build a community's page link from what we already know (builder + name + city), so a link never 13132// depends on the copied page including the href. Most builders use a predictable URL; for the ones whose
13133// URL needs an ID or a region we can't derive (Richmond, Fieldstone, or anything unrecognized), fall back 13134// to a web search that reliably lands on the community page. This runs at render time, so every 13135// community â old or new â is clickable with no stored data and no re-paste needed. 13136function _ncSlug(s){ return String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,''); } 13137function ncGuessUrl(builder,name,city){ 13138 var b=String(builder||'').toLowerCase(), ns=_ncSlug(name), cs=_ncSlug(city); 13139 if(!ns && !name) return ''; 13140 if(/edge/.test(b) && ns) return 'https://www.edgehomes.com/communities/'+ns+'/'; 13141 if(/ivory/.test(b) && ns) return 'https://ivoryhomes.com/community-details/'+ns; 13142 if(/lennar/.test(b) && ns && cs)return 'https://www.lennar.com/new-homes/utah/salt-lake-city/'+cs+'/'+ns+'/'; 13143 if(/horton/.test(b) && ns && cs)return 'https://www.drhorton.com/utah/salt-lake-city/'+cs+'/'+ns; 13144 // Fallback: a web search for "<builder> <community> <city> Utah new homes" â always resolves, never 404s.
13145 var q=[builder,name,city,'Utah new homes'].filter(Boolean).join(' '); 13146 return 'https://www.google.com/search?q='+encodeURIComponent(q); 13147} 13148// ---- builder-sender list (settings key 'nc_builders') ---- 13149async function loadNcBuilders(){ 13150 if(!supaReady) return NC_BUILDERS; 13151 try{ var headers=await getAuthHeaders(); 13152 var r=await fetch(SUPA_URL+'/rest/v1/settings?key=eq.nc_builders&select=value',{ headers:headers }); 13153 var text=await r.text(); if(text){ var rows=JSON.parse(text); if(rows.length && Array.isArray(rows[0].value)) NC_BUILDERS=rows[0].value; } 13154 }catch(e){} 13155 return NC_BUILDERS; 13156} 13157async function saveNcBuilders(){ 13158 if(!supaReady){ alert('Connect to Supabase first.'); return false; } 13159 try{ var headers=await getAuthHeaders({'Prefer':'resolution=merge-duplicates'}); 13160 var r=await fetch(SUPA_URL+'/rest/v1/settings',{ method:'POST', headers:headers, body:JSON.stringify({ key:'nc_builders', value:NC_BUILDERS }) }); 13161 return r.ok; 13162 }catch(e){ return false; } 13163} 13164function _ncStyle(){ 13165 if(ge('ncCss')) return; var s=document.createElement('style'); s.id='ncCss'; 13166 s.textContent= 13167 '#page-newconstruction .ncbtn{background:var(--accent);color:#fff;border:none;border-radius:7px;padding:7px 14px;font-family:inherit;font-size:14px;cursor:pointer;}' 13168 +'#page-newconstruction .ncbtn.g{background:transparent;border:1px solid var(--border);color:var(--text);}' 13169 +'#page-newconstruction .nc-form{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:14px;margin-bottom:16px;}' 13170 +'#page-newconstruction .nc-form label{font-size:12px;color:var(--text3);display:block;margin-bottom:3px;}' 13171 +'#page-newconstruction .nc-form input,#page-newconstruction .nc-form textarea,#page-newconstruction .nc-form select{width:100%;box-sizing:border-box;background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-family:inherit;font-size:13px;}' 13172 +'#page-newconstruction .nc-wide{grid-column:1/-1;}' 13173 +'#page-newconstruction .nc-badge{display:inline-block;font-size:10px;font-weight:700;color:#fff;border-radius:4px;padding:2px 7px;vertical-align:middle;}'; 13174 document.head.appendChild(s); 13175} 13176function renderNewConstruction(){ 13177 _ncStyle(); var root=ge('ncRoot'); if(!root) return; root.innerHTML=''; loadNcBuilders(); 13178 var bar=document.createElement('div'); bar.style.cssText='display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;'; 13179 bar.appendChild(mkDivSafe('font-size:22px;font-weight:600;','New Construction')); 13180 var bw=document.createElement('div'); bw.style.cssText='display:flex;gap:8px;flex-wrap:wrap;'; 13181 var paste=document.createElement('button'); paste.className='ncbtn'; paste.textContent='Paste from a page'; paste.addEventListener('click',ncPasteModal); bw.appendChild(paste); 13182 var scan=document.createElement('button'); scan.className='ncbtn g'; scan.textContent='Scan builder emails'; scan.addEventListener('click',function(){ scanBuilderEmails(scan); }); bw.appendChild(scan); 13183 var mb=document.createElement('button'); mb.className='ncbtn g'; mb.textContent='Manage builders'; mb.addEventListener('click',ncBuildersModal); bw.appendChild(mb); 13184 var add=document.createElement('button'); add.className='ncbtn g'; add.textContent='+ Add manually'; add.addEventListener('click',function(){ openNC(ncDefault(),true); }); bw.appendChild(add); 13185 bar.appendChild(bw); root.appendChild(bar); 13186 root.appendChild(mkDivSafe('color:var(--text3);font-size:12px;margin:-2px 0 12px;line-height:1.5;','Open a builderâs community page in your browser, copy it, and use <b>Paste from a page</b> to file every community here. Or, if youâre on a builderâs <b>agent email list</b>, use <b>Scan builder emails</b>. Add any by hand too.')); 13187 if(!NC.length){ root.appendChild(mkDivSafe('color:var(--text3);padding:20px 0;','Nothing yet. Set up your builders, subscribe to their agent emails, then scan â or add a community by hand.')); return; } 13188 var ctr=document.createElement('div'); ctr.style.cssText='display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px;'; 13189 var search=document.createElement('input'); search.type='text'; search.placeholder='Search community / cityâ¦'; search.value=_ncQuery; 13190 search.style.cssText='flex:1;min-width:170px;background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:7px 10px;font-family:inherit;font-size:13px;'; 13191 var bsel=document.createElement('select'); bsel.style.cssText='background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:7px 8px;font-family:inherit;font-size:13px;width:auto;'; 13192 var builders={}; NC.forEach(function(x){ if(x.builder) builders[x.builder]=true; }); 13193 [['','All builders']].concat(Object.keys(builders).sort().map(function(b){return [b,b];}
13193)).forEach(function(o){ var op=document.createElement('option'); op.value=o[0]; op.textContent=o[1]; if(o[0]===_ncBuilder)op.selected=true; bsel.appendChild(op); }); 13194 var tsel=document.createElement('select'); tsel.style.cssText=bsel.style.cssText; 13195 [['','All types'],['single family','Single Family'],['townhome','Townhome'],['condo','Condo']].forEach(function(o){ var op=document.createElement('option'); op.value=o[0]; op.textContent=o[1]; if(o[0]===_ncType)op.selected=true; tsel.appendChild(op); }); 13196 var ssel=document.createElement('select'); ssel.style.cssText=bsel.style.cssText; 13197 [['active','Active'],['all','All'],['updated','Updated this week']].concat(NC_STATUS).concat([['archived','Archived']]).forEach(function(o){ var op=document.createElement('option'); op.value=o[0]; op.textContent=o[1]; if(o[0]===_ncView)op.selected=true; ssel.appendChild(op); }); 13198 var sortsel=document.createElement('select'); sortsel.style.cssText=bsel.style.cssText; 13199 [['city','Sort: City'],['price_lo','Sort: Price â'],['price_hi','Sort: Price â'],['name','Sort: Name'],['updated','Sort: Recent']].forEach(function(o){ var op=document.createElement('option'); op.value=o[0]; op.textContent=o[1]; if(o[0]===_ncSort)op.selected=true; sortsel.appendChild(op); }); 13200 ctr.appendChild(search); ctr.appendChild(bsel); ctr.appendChild(tsel); ctr.appendChild(ssel); ctr.appendChild(sortsel); root.appendChild(ctr); 13201 var list=document.createElement('div'); list.style.cssText='display:flex;flex-direction:column;gap:6px;'; root.appendChild(list); 13202 function rowEl(x){ 13203 var d=x.data||{}, arch=ncArchived(x); 13204 var row=document.createElement('div'); row.style.cssText='display:flex;justify-content:space-between;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:10px 14px;cursor:pointer;margin-left:6px;'+(arch?'opacity:.7;':''); 13205 var badge='<span class="nc-badge" style="background:'+ncStatusColor(d.status)+';">'+_esc(ncStatusLabel(d.status||'active'))+'</span>'; 13206 var fresh=ncRecent(x)?' <span class="nc-badge" style="background:#d08b1f;">NEW/UPDATED</span>':''; 13207 var sub=[d.city, d.home_types, ncPrice(d)].filter(Boolean).join(' · '); 13208 var promo=d.promo?('<div style="font-size:12px;color:#1f9d55;margin-top:2px;">'+_esc(d.promo)+'</div>'):''; 13209 var nm=_esc(x.name||'(community)'); 13210 // The link is computed from builder+name+city (with any captured URL taking precedence), so every 13211 // community is clickable without depending on the pasted page including links. 13212 var pageUrl=(d.url||'').trim() || ncGuessUrl(x.builder, x.name, d.city); 13213 var nameHtml = pageUrl ? '<a class="nc-link" href="'+_esc(pageUrl)+'" target="_blank" rel="noopener" style="color:#b8912f;text-decoration:none;">'+nm+' <span style="font-size:11px;">â</span></a>' : nm; 13214 row.innerHTML='<div style="min-width:0;"><div style="font-weight:600;overflow:hidden;text-overflow:ellipsis;">'+nameHtml+' '+badge+fresh+(arch?' <span class="nc-badge" style="background:#888;">ARCHIVED</span>':'')+'</div><div style="font-size:12px;color:var(--text3);">'+_esc(sub)+'</div>'+promo+'</div>'; 13215 var _a=row.querySelector('a.nc-link'); if(_a) _a.addEventListener('click',function(e){ e.stopPropagation(); }); 13216 var btns=document.createElement('div'); btns.style.cssText='display:flex;gap:6px;flex-shrink:0;'; 13217 if(pageUrl){ var ob=document.createElement('button'); ob.className='ncbtn'; ob.style.cssText='font-size:12px;padding:3px 9px;'; ob.textContent='Open â'; 13218 ob.addEventListener('click',function(e){ e.stopPropagation(); try{ window.open(pageUrl,'_blank','noopener'); }catch(err){} }); btns.appendChild(ob); } 13219 var eb=document.createElement('button'); eb.className='ncbtn g'; eb.style.cssText='font-size:12px;padding:3px 9px;'; eb.textContent='Edit'; 13220 eb.addEventListener('click',function(e){ e.stopPropagation(); openNC(x,false); }); 13221 var ab=document.createElement('button'); ab.className='ncbtn g'; ab.style.cssText='font-size:12px;padding:3px 9px;'; ab.textContent=arch?'Unarchive':'Archive'; 13222 ab.addEventListener('click',function(e){ e.stopPropagation(); if(!x.data)x.data={}; x.data.archived=!arch; saveNC(x); draw(); }); 13223 var del=document.createElement('button'); del.className='ncbtn g'; del.style.cssText='font-size:12px;padding:3px 9px;'; del.textContent='Delete'; 13224 (function(id){ del.addEventListener('click',function(e){ e.stopPropagation(); if(confirm('Delete this community?')){ delNC(id); draw(); } }); })(x.id); 13225 btns.appendChild(eb); btns.appendChild(ab); btns.appendChild(del); 13226 row.addEventListener('click',function(){ if(pageUrl){ try{ window.open(pageUrl,'_blank','noopener'); }catch(e){} } else { openNC(x,false); } }); 13227 row.appendChild(btns); return row; 13228 } 13229 function cmp(a,b){ 13230 var da=a.data||{}, db=b.data||{}; 13231 if(_ncSort==='name') return String(a.name||'').localeCompare(String(b.name||'')); 13232 if(_ncSort==='updated') return String(b.updated_at||'').localeCompare(String(a.updated_at||'')); 13233 if(_ncSort==='price_lo'){ return (ncPriceNum(da)||9e15)-(ncPriceNum(db)||9e15); } 13234 if(_ncSort==='price_hi'){ return (ncPriceNum(db)|| -1)-(ncPriceNum(da)|| -1); } 13235 return String(da.city||'').localeCompare(String(db.city||'')) || (ncPriceNum(da)-ncPriceNum(db)); 13236 } 13237 function draw(){ 13238 list.innerHTML=''; var q=_ncQuery.trim().toLowerCase(); 13239 var items=NC.filter(function(x){ 13240 var d=x.data||{}, arch=ncArchived(x); 13241 if(_ncBuilder && x.builder!==_ncBuilder) return false;
13242 if(_ncType && ncType(d)!==_ncType) return false; 13243 if(q){ var hay=((x.name||'')+' '+(d.city||'')+' '+(x.builder||'')+' '+(d.home_types||'')).toLowerCase(); if(hay.indexOf(q)<0) return false; } 13244 if(_ncView==='archived') return arch; 13245 if(arch) return false; 13246 if(_ncView==='active') return (d.status||'active')!=='sold_out'; 13247 if(_ncView==='all') return true; 13248 if(_ncView==='updated') return ncRecent(x); 13249 return d.status===_ncView; 13250 }); 13251 if(!items.length){ list.appendChild(mkDivSafe('color:var(--text3);padding:16px 0;','Nothing matches.')); return; } 13252 var groups={}, order=[]; items.forEach(function(x){ var b=x.builder||'(Unspecified)'; if(!groups[b]){ groups[b]=[]; order.push(b); } groups[b].push(x); }); 13253 order.sort(function(a,b){ return a.toLowerCase().localeCompare(b.toLowerCase()); }); 13254 order.forEach(function(b){ 13255 var arr=groups[b].slice().sort(cmp); var collapsed=!!_ncCollapse[b]; 13256 var hdr=document.createElement('div'); hdr.style.cssText='display:flex;justify-content:space-between;align-items:center;gap:8px;user-select:none;background:var(--surface3);border:1px solid var(--border);border-radius:7px;padding:9px 12px;margin-top:10px;'; 13257 var left=document.createElement('div'); left.style.cssText='cursor:pointer;flex:1;min-width:0;'; 13258 left.innerHTML='<span style="font-weight:700;font-size:15px;">'+_esc(b)+' <span style="color:var(--text3);font-weight:400;font-size:13px;">('+arr.length+')</span></span> <span style="opacity:.6;font-size:12px;">'+(collapsed?'â¸':'â¾')+'</span>'; 13259 left.addEventListener('click',function(){ _ncCollapse[b]=!_ncCollapse[b]; draw(); }); 13260 var clr=document.createElement('button'); clr.className='ncbtn g'; clr.style.cssText='font-size:11px;padding:2px 8px;flex-shrink:0;'; clr.textContent='Clear all'; 13261 (function(bn){ clr.addEventListener('click',function(e){ e.stopPropagation(); var all=NC.filter(function(x){ return (x.builder||'(Unspecified)')===bn; }); if(confirm('Delete ALL '+all.length+' communities for '+bn+'? This cannot be undone.')){ all.map(function(x){return x.id;}).forEach(function(id){ delNC(id); }); draw(); } }); })(b); 13262 hdr.appendChild(left); hdr.appendChild(clr); 13263 list.appendChild(hdr); 13264 if(collapsed) return; 13265 arr.forEach(function(x){ list.appendChild(rowEl(x)); }); 13266 }); 13267 } 13268 search.addEventListener('input',function(){ _ncQuery=search.value; draw(); }); 13269 bsel.addEventListener('change',function(){ _ncBuilder=bsel.value; draw(); }); 13270 tsel.addEventListener('change',function(){ _ncType=tsel.value; draw(); }); 13271 ssel.addEventListener('change',function(){ _ncView=ssel.value; draw(); }); 13272 sortsel.addEventListener('change',function(){ _ncSort=sortsel.value; draw(); }); 13273 draw(); 13274} 13275function openNC(x, isNew){ 13276 _ncStyle(); var root=ge('ncRoot'); if(!root) return; root.innerHTML=''; var d=x.data; 13277 var tb=document.createElement('div'); tb.style.cssText='display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px;'; 13278 var back=document.createElement('button'); back.className='ncbtn g'; back.textContent='â¹ Back'; back.addEventListener('click',renderNewConstruction); tb.appendChild(back); 13279 var saveB=document.createElement('button'); saveB.className='ncbtn'; saveB.textContent='Save'; saveB.addEventListener('click',function(){ if(!x.name) x.name='(community)'; if(!NC.some(function(y){return String(y.id)===String(x.id);})) NC.push(x); saveNC(x); saveB.textContent='Saved â'; setTimeout(function(){saveB.textContent='Save';},1500); }); tb.appendChild(saveB); 13280 var _openUrl=(d.url||'').trim() || ncGuessUrl(x.builder, x.name, d.city); 13281 if(_openUrl){ var open=document.createElement('button'); open.className='ncbtn g'; open.textContent='Open listing â'; open.addEventListener('click',function(){ try{ window.open(_openUrl,'_blank','noopener'); }catch(e){} }); tb.appendChild(open); } 13282 root.appendChild(tb); 13283 var form=document.createElement('div'); form.className='nc-form'; 13284 function fld(get,set,label,type,wide){ var w=document.createElement('div'); if(wide)w.className='nc-wide'; var l=document.createElement('label'); l.textContent=label; w.appendChild(l); 13285 var el=type==='textarea'?document.createElement('textarea'):document.createElement('input'); if(type&&type!=='textarea')el.type=type; if(type==='textarea')el.rows=3; 13286 el.value=(get()!=null?get():''); el.addEventListener('input',function(){ set(el.value); }); w.appendChild(el); form.appendChild(w); } 13287 function dfld(key,label,type,wide){ fld(function(){return d[key];},function(v){d[key]=v;},label,type,wide); } 13288 function pick(key,label,opts){ var w=document.createElement('div'); var l=document.createElement('label'); l.textContent=label; w.appendChild(l); 13289 var s=document.createElement('select');
13289 opts.forEach(function(o){ var op=document.createElement('option'); op.value=o[0]; op.textContent=o[1]; if(d[key]===o[0])op.selected=true; s.appendChild(op); }); s.value=d[key]; s.addEventListener('change',function(){ d[key]=s.value; }); w.appendChild(s); form.appendChild(w); } 13290 fld(function(){return x.builder;},function(v){x.builder=v;},'Builder'); 13291 fld(function(){return x.name;},function(v){x.name=v;},'Community name'); 13292 dfld('city','City'); dfld('state','State'); pick('status','Status',NC_STATUS); 13293 dfld('home_types','Home types (single family / townhomeâ¦)','text',true); 13294 dfld('price_text','Price (as shown, e.g. "From the $530âs")','text',true); 13295 dfld('price_min','Price min ($, optional)','number'); dfld('price_max','Price max ($, optional)','number'); 13296 dfld('promo','Promotion / incentive','textarea',true); 13297 dfld('url','Listing URL','text',true); 13298 dfld('notes','Notes','textarea',true); 13299 root.appendChild(form); 13300} 13301function ncBuildersModal(){ 13302 loadNcBuilders().then(function(){ _ncBuildersModalDraw(); }); 13303} 13304function _ncBuildersModalDraw(){ 13305 var ex=ge('ncBuildersOv'); if(ex){ try{ document.body.removeChild(ex); }catch(e){} } 13306 var ov=document.createElement('div'); ov.id='ncBuildersOv'; ov.className='modal-ov open'; ov.style.zIndex='1300'; 13307 var m=document.createElement('div'); m.className='modal'; m.style.maxWidth='620px'; 13308 var h=document.createElement('div'); h.style.cssText='display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border);'; 13309 h.innerHTML='<div style="font-weight:700;font-size:17px;">Builders & their email senders</div>'; 13310 var x=document.createElement('button'); x.textContent='â'; x.style.cssText='background:none;border:none;font-size:18px;color:var(--text3);cursor:pointer;'; x.addEventListener('click',function(){ document.body.removeChild(ov); }); h.appendChild(x); m.appendChild(h); 13311 var body=document.createElement('div'); body.style.cssText='padding:16px 20px;display:flex;flex-direction:column;gap:10px;max-height:66vh;overflow:auto;'; 13312 body.appendChild(mkDivSafe('font-size:12px;color:var(--text3);line-height:1.5;','For each builder, list the email addresses or domains their agent updates come from (comma-separated), e.g. <i>edgehomes.com, [email protected]</i>. The scanner searches your Gmail for these senders.')); 13313 NC_BUILDERS.forEach(function(b,idx){ 13314 var card=document.createElement('div'); card.style.cssText='border:1px solid var(--border);border-radius:8px;padding:10px 12px;display:flex;flex-direction:column;gap:6px;'; 13315 function inp(val,ph,cb){ var i=document.createElement('input'); i.value=val||''; i.placeholder=ph; i.style.cssText='width:100%;box-sizing:border-box;background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-family:inherit;font-size:13px;'; i.addEventListener('input',function(){ cb(i.value); }); return i; } 13316 card.appendChild(inp(b.name,'Builder name',function(v){ b.name=v; })); 13317 card.appendChild(inp(b.senders,'Sender emails/domains (comma-separated)',function(v){ b.senders=v; })); 13318 card.appendChild(inp(b.url,'Website (optional)',function(v){ b.url=v; })); 13319 var del=document.createElement('button'); del.className='btn btn-g'; del.style.cssText='align-self:flex-start;font-size:12px;padding:3px 9px;'; del.textContent='Remove'; del.addEventListener('click',function(){ NC_BUILDERS.splice(idx,1); _ncBuildersModalDraw(); }); card.appendChild(del); 13320 body.appendChild(card); 13321 }); 13322 m.appendChild(body); 13323 var ft=document.createElement('div'); ft.style.cssText='display:flex;justify-content:space-between;gap:8px;padding:14px 20px;border-top:1px solid var(--border);'; 13324 var addb=document.createElement('button'); addb.className='btn btn-g'; addb.textContent='+ Add builder'; addb.addEventListener('click',function(){ NC_BUILDERS.push({name:'',senders:'',url:''}); _ncBuildersModalDraw(); }); 13325 var saveb=document.createElement('button'); saveb.className='btn btn-p'; saveb.textContent='Save'; 13326 saveb.addEventListener('click',function(){ NC_BUILDERS=NC_BUILDERS.filter(function(b){ return (b.name||'').trim()||(b.senders||'').trim(); }); saveNcBuilders().then(function(ok){ if(ok){ document.body.removeChild(ov); } else { alert('Save failed.'); } }); }); 13327 ft.appendChild(addb); ft.appendChild(saveb); m.appendChild(ft); 13328 ov.appendChild(m); ov.addEventListener('click',function(e){ if(e.target===ov) document.body.removeChild(ov); }); 13329 document.body.appendChild(ov); 13330} 13331function _ncExtractPrompt(corpus){ 13332 return 'This is text from a new-home BUILDER website or email listing MULTIPLE communities. Extract EVERY ' 13333 +'new-construction community â there are usually many (often 10-30); do not stop after one. ' 13334 +'Return ONLY a JSON object of the exact form {"communities": [ ... ]}, where the array has one entry per ' 13335 +'community: {"builder":string,"community":string,"city":string,"state":string,"home_types":string,' 13336 +'"price_text":string,"promo":string,"status":one of ["active","coming_soon","sold_out","unknown"]}. ' 13337 +'Rules: include only real named communities (skip nav, footers, generic marketing). Keep every value short ' 13338 +'(under ~60 characters); never output a whole URL or any number longer than 7 digits. ' 13339 +'Extract city and state even when they only appear inside a URL slug â e.g. ".../Amberly-Place-North-Salt-Lake-UT-84054/" ' 13340 +'means city "North Salt Lake", state "UT". Always fill city and state when derivable. ' 13341 +'price_text = the starting price if shown (e.g. "From the $530âs" or "From $800,000"), else "". ' 13342 +'home_types = the type as written (Single Family, Townhome, Condoâ¦) or "" if not shown. '
13343 +'promo = any incentive (rate buydown, $ toward options, price drop), else "". Infer builder from the branding.\n\nSOURCE TEXT:\n'+String(corpus||'').slice(0,45000); 13344} 13345// Schema wraps the list in an object property â Gemini returns a full array this way instead of 13346// collapsing a list to a single object. URL and numeric price fields are omitted on purpose: they 13347// triggered a runaway-digits loop that truncated the response. 13348var NC_SCHEMA={ type:'OBJECT', properties:{ communities:{ type:'ARRAY', items:{ type:'OBJECT', properties:{ 13349 builder:{type:'STRING'}, community:{type:'STRING'}, city:{type:'STRING'}, state:{type:'STRING'}, 13350 home_types:{type:'STRING'}, price_text:{type:'STRING'}, promo:{type:'STRING'}, status:{type:'STRING'} } } } }, 13351 required:['communities'] }; 13352// Call the AI extractor and normalize its output to an array of community items, however it comes back. 13353async function ncExtractFromText(corpus){ 13354 var resp=await fetch('/api/claude',{ method:'POST', headers:await apiHeaders(), body:JSON.stringify({ max_tokens:8192, response_format:'json', response_schema:NC_SCHEMA, messages:[{ role:'user', content:_ncExtractPrompt(corpus) }] }) }); 13355 var data=await resp.json(); if(data&&data.error) throw new Error(data.error.message||JSON.stringify(data.error)); 13356 var raw=(data.content&&data.content[0]&&data.content[0].text)||''; 13357 var parsed=_parseJsonLoose(raw); var arr=[]; 13358 if(Array.isArray(parsed)) arr=parsed; 13359 else if(parsed && typeof parsed==='object'){ 13360 if(Array.isArray(parsed.communities)) arr=parsed.communities; 13361 else { var found=null; for(var k in parsed){ if(Array.isArray(parsed[k])){ found=parsed[k]; break; } } arr = found || ((parsed.community||parsed.name)?[parsed]:[]); } 13362 } 13363 // Salvage: if the JSON was truncated (e.g. a runaway field), recover whatever complete community 13364 // objects we can from the raw text so a partial response still yields most of the list. 13365 if(!arr.length && /"community"\s*:/.test(raw)){ 13366 (raw.match(/\{[^{}]*"community"\s*:[^{}]*\}/g)||[]).forEach(function(o){ try{ arr.push(JSON.parse(o)); }catch(e){} }); 13367 } 13368 return { arr:arr, raw:raw }; 13369} 13370// Upsert an extracted array of communities into NC (used by both the email scan and paste-from-page). 13371function ncApplyExtracted(arr, sourceLabel){ 13372 var added=0, updated=0, today=tod(); 13373 (arr||[]).forEach(function(it){ 13374 var name=(it.community||'').trim(); if(!name) return; 13375 var builder=(it.builder||'').trim(); 13376 var rec=ncFind(builder,name); 13377 if(rec){ 13378 var d=rec.data||(rec.data={}); var changed=false; 13379 ['city','state','home_types','price_text','promo','url'].forEach(function(k){ if(it[k]!=null && String(it[k]).trim() && String(it[k])!==String(d[k]||'')){ d[k]=String(it[k]).trim(); changed=true; } }); 13380 // Heal builder spelling/casing to the incoming (canonical) name â matching is case-insensitive, so 13381 // a re-paste of "D.R. Horton" over an old "d.r. horton" record fixes the label in place. 13382 if(builder && rec.builder!==builder){ rec.builder=builder; changed=true; } 13383 if(it.price_min!=null){ d.price_min=it.price_min; } if(it.price_max!=null){ d.price_max=it.price_max; } 13384 if(it.status && it.status!==d.status){ d.status=it.status; changed=true; } 13385 d.last_seen=today; if(changed){ updated++; saveNC(rec); } 13386 } else { 13387 var nrec={ id:Date.now()+Math.floor(Math.random()*100000)+added, builder:builder, name:name, data:{ 13388 city:(it.city||'').trim(), state:(it.state||'UT').trim(), home_types:(it.home_types||'').trim(), 13389 price_text:(it.price_text||'').trim(), price_min:(it.price_min!=null?it.price_min:''), price_max:(it.price_max!=null?it.price_max:''), 13390 promo:(it.promo||'').trim(), status:(it.status||'active'), url:(it.url||'').trim(), source:sourceLabel||'manual', email_ref:'', notes:'', 13391 first_seen:today, last_seen:today, archived:false } }; 13392 NC.push(nrec); saveNC(nrec); added++; 13393 } 13394 }); 13395 return { added:added, updated:updated }; 13396} 13397// Lennar-specific parse. Lennar's listing pages show individual move-in-ready homes (one block per 13398// home), not communities. Each block links to a URL like 13399// lennar.com/new-homes/utah/salt-lake-city/{city}/{community}/{plan}/{homesite-id} 13400// so the city and community are reliably in the URL path, and the "Homesite #⦠{Plan} in {Community}"
13401// line gives the community's display name. We roll the homes up into one record per community, using the 13402// lowest home price as the community's "From $â¦". City/state are taken from the URL slug. 13403function _slugTitle(s){ return String(s||'').split('-').map(function(w){ return w? w.charAt(0).toUpperCase()+w.slice(1) : w; }).join(' ').trim(); } 13404function ncParseLennar(text){ 13405 var norm=String(text||'').replace(/\r/g,''); 13406 // Price per home URL (skip promo links). 13407 var priceByUrl={}; 13408 var pr=/\[\$([\d,]+)\]\((https?:\/\/[^)]*lennar\.com\/new-homes\/[^)]+)\)/gi, pm; 13409 while((pm=pr.exec(norm))){ 13410 var url=pm[2]; if(/\/promo\//i.test(url)) continue; 13411 var n=parseInt(pm[1].replace(/,/g,''),10); if(isNaN(n)) continue; 13412 if(priceByUrl[url]==null || n<priceByUrl[url]) priceByUrl[url]=n; 13413 } 13414 // "Homesite #NNNN {Plan} in {Community}" line carries the community display name + the home URL. 13415 var comm={}; 13416 var hr=/Homesite\s*#\d+[^\]\n]*?\bin\b\s+([^\]\n]+?)\]\((https?:\/\/[^)]*lennar\.com\/new-homes\/[^)]+)\)/gi, hm; 13417 while((hm=hr.exec(norm))){ 13418 var cname=(hm[1]||'').trim(), url=hm[2]; 13419 if(/\/promo\//i.test(url)) continue; 13420 var seg=url.split('/new-homes/')[1]; if(!seg) continue; 13421 var parts=seg.split('/'); // [state, metro, city, community, plan, homesite...] 13422 var citySlug=parts[2]||'', commSlug=(parts[3]||cname).toLowerCase(); 13423 if(!comm[commSlug]){ 13424 var cu=url.split('/new-homes/')[0]+'/new-homes/'+parts.slice(0,4).join('/')+'/'; // community landing page 13425 comm[commSlug]={ name:cname, city:_slugTitle(citySlug), min:null, url:cu }; 13426 } 13427 var price=priceByUrl[url]; 13428 if(price!=null && (comm[commSlug].min==null || price<comm[commSlug].min)) comm[commSlug].min=price; 13429 } 13430 var out=[]; 13431 Object.keys(comm).forEach(function(k){ var c=comm[k]; 13432 out.push({ builder:'Lennar', community:c.name, city:c.city, state:'UT', 13433 home_types:ncGuessType(c.name), price_text:(c.min!=null?('From $'+c.min.toLocaleString('en-US')):''), 13434 promo:'', url:c.url||'', status:'active' }); 13435 }); 13436 return out; 13437} 13438// D.R. Horton-specific parse. Each block on Horton's community list is already one community (not 13439// individual homes), and every link in a block points to that community's URL 13440// drhorton.com/utah/salt-lake-city/{city}/{community} 13441// so we group links by URL and read the community name, city, price, and status out of the block's 13442// texts. The displayed "City, ST ZIP" line is preferred over the URL city slug (they can differ, e.g. 13443// Riley Park's URL says "erda" but it markets as Tooele). 13444function ncParseDRHorton(text){ 13445 var norm=String(text||'').replace(/\r/g,''); 13446 var byUrl={}, order=[]; 13447 var lr=/\[([^\]]+)\]\((https?:\/\/[^)]*drhorton\.com\/[^)]+)\)/gi, lm; 13448 while((lm=lr.exec(norm))){ 13449 var txt=lm[1].trim(), url=lm[2]; 13450 if(!byUrl[url]){ var seg=url.split('drhorton.com/')[1]||''; var parts=seg.split(/[\/?#]/); 13451 byUrl[url]={ texts:[], citySlug:parts[2]||'', slug:(parts[3]||'').toLowerCase() }; order.push(url); } 13452 if(byUrl[url].texts.indexOf(txt)<0) byUrl[url].texts.push(txt); 13453 } 13454 function isStatus(t){ return /now selling|coming soon|sold out|new phase|model now open|grand opening|final home|move-in ready/i.test(t); } 13455 function isCity(t){ return /,\s*[A-Z]{2}\s+\d{5}/.test(t); } 13456 function isPrice(t){ return /\$|call for/i.test(t); } 13457 function isBeds(t){ return /\bbed\b/i.test(t); } 13458 function isSqft(t){ return /sq\.?\s*ft/i.test(t); } 13459 var out=[]; 13460 order.forEach(function(url){ var b=byUrl[url]; 13461 var name='', city='', state='UT', price='', status='active', nameCands=[]; 13462 b.texts.forEach(function(t){ 13463 if(isCity(t)){ var cm=t.match(/^(.+?),\s*([A-Z]{2})/); if(cm){ city=cm[1].trim(); state=cm[2]; } return; } 13464 if(isSqft(t)) return; 13465 if(isBeds(t)) return; 13466 if(isPrice(t)){ if(/\$/.test(t)) price=t.trim(); return; } 13467 if(isStatus(t)){ if(/coming soon/i.test(t)) status='coming_soon'; else if(/sold out/i.test(t)) status='sold_out'; return; } 13468 nameCands.push(t); 13469 }); 13470 if(nameCands.length){ 13471 var match=nameCands.filter(function(t){ return t.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'')===b.slug; }); 13472 name=(match[0]||nameCands[0]).trim(); 13473 } else { name=_slugTitle(b.slug); } 13474 if(!name) return; 13475 out.push({ builder:'D.R. Horton', community:name, city:city||_slugTitle(b.citySlug), state:state, 13476 home_types:ncGuessType(name), price_text:price, promo:'', url:url, status:status }); 13477 }); 13478 return out; 13479} 13480// Fieldstone-specific parse. Fieldstone's list is plain text (no per-community links), grouped under 13481// "Active" and "Coming Soon" headers. Each priced community is Name / "City, UT" / "From $price" / 13482// sqft / beds / baths. Coming-soon communities have a name + city but no price. We read the visible 13483// list; the featured-community repeat (with a ZIP) and the footer nav names are de-duplicated / ignored. 13484function ncParseFieldstone(text){ 13485 var raw=String(text||'').replace(/\r/g,''); 13486 // The footer "Communities" list links each community to its page â map name -> URL before stripping. 13487 var urlByName={}; 13488 var ur=/\[([^\]]+)\]\((https?:\/\/[^)]*fieldstonehomes\.com\/new-homes\/[^)]+)\)/gi, um; 13489 while((um=ur.exec(raw))){ var u=um[2].replace(/#.*$/,''); if(/\/new-homes\/ut\//i.test(u)) urlByName[um[1].trim().toLowerCase()]=u; } 13490 var norm=raw.replace(/\[([^\]]*)\]\([^)]*\)/g,'$1'); 13491 var out=[], seen={}; 13492 function add(name,city,price,status){ 13493 name=(name||'').trim(); city=(city||'').trim(); if(!name||name.length>60) return; 13494 var k=name.toLowerCase(); if(seen[k]) return; seen[k]=1; 13495 out.push({ builder:'Fieldstone Homes', community:name, city:city, state:'UT', 13496 home_types:ncGuessType(name), price_text:(price||'').trim(), promo:'', url:urlByName[k]||'', status:status||'active' }); 13497 } 13498 var re=/([^\n]+)\n([A-Za-z][A-Za-z .'\-]*),\s*UT(?:\s+\d{5}
13498)?\n(From (?:the )?\$[\d,]+s?)/g, m; 13499 while((m=re.exec(norm))){ add(m[1], m[2], m[3], 'active'); } 13500 var cs=norm.match(/Coming Soon\n([\s\S]*?)(?:\nFind Your Home|\nWhat People|\nNew Homes in Utah|\nCommunities\n|$)/); 13501 if(cs){ var re2=/([^\n]+)\n([A-Za-z][A-Za-z .'\-]*),\s*UT(?:\s+\d{5})?/g, m2; 13502 while((m2=re2.exec(cs[1]))){ add(m2[1], m2[2], '', 'coming_soon'); } } 13503 return out; 13504} 13505// Richmond American-specific parse. Like Lennar, the page lists individual move-in-ready homes. Each 13506// home block is: "$price" / "[$x/mo.](home-url)" / details / "Ready for move-in ⦠[Community](comm-url) 13507// in City, UT / County". We roll homes up per community, using the lowest home price as "From $â¦", and 13508// take the community landing URL and city from the "Ready for move-in" line. 13509function ncParseRichmond(text){ 13510 var norm=String(text||'').replace(/\r/g,''); var comm={}, order=[]; 13511 var re=/\$([\d,]+)\s*\n[\s\S]*?Ready for move-in[^\[]*\[([^\]]+)\]\((https?:\/\/[^)]*richmondamerican\.com\/[^)]+)\)\s+in\s+([^,\n]+),\s*UT/gi, m; 13512 while((m=re.exec(norm))){ 13513 var price=parseInt(m[1].replace(/,/g,''),10); 13514 var name=(m[2]||'').trim(), url=(m[3]||'').trim(), city=(m[4]||'').trim(); 13515 var slug=url.replace(/[#?].*$/,'').replace(/\/+$/,'').split('/').pop().toLowerCase(); 13516 var key=slug||name.toLowerCase(); 13517 if(!comm[key]){ comm[key]={ name:name, city:city, url:url, min:(isNaN(price)?null:price) }; order.push(key); } 13518 else if(!isNaN(price) && (comm[key].min==null || price<comm[key].min)) comm[key].min=price; 13519 } 13520 var out=[]; 13521 order.forEach(function(k){ var c=comm[k]; 13522 out.push({ builder:'Richmond American Homes', community:c.name, city:c.city, state:'UT', 13523 home_types:ncGuessType(c.name), price_text:(c.min!=null?('From $'+c.min.toLocaleString('en-US')):''), 13524 promo:'', url:c.url||'', status:'active' }); 13525 }); 13526 return out; 13527} 13528// Deterministic parse of a copied builder listing page. Works whether the copy came through as plain 13529// text (one field per line, or space-joined) or as markdown links â it strips any links, then finds 13530// each community by its Name / TYPE / City, ST ZIP / Price pattern, with an optional incentive line. 13531// Fast and free; the AI is only a fallback for formats this doesn't recognize. 13532function ncLocalParse(text, builderHint){ 13533 var raw=String(text||'').replace(/\r/g,''); 13534 // Capture community-page URLs by slug before stripping the links (Edge: every field links to the 13535 // community page; Ivory: the [View] link). Attached to each parsed community via its name's slug. 13536 var urlBySlug={}; 13537 (function(){ var lr=/\]\((https?:\/\/[^)]+)\)/g, lm; while((lm=lr.exec(raw))){ var mm=lm[1].match(/\/(?:communities|community-details)\/([^\/?#)]+)/i); if(mm) urlBySlug[mm[1].toLowerCase()]=lm[1].replace(/[)#].*$/,''); } })(); 13538 function _slug(s){ return String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,''); } 13539 var norm=raw.replace(/\[([^\]]*)\]\([^)]*\)/g,'$1'); 13540 var typeRe='SINGLE FAMILY|TOWNHOMES?|CONDOS?|TWINHOMES?|TWIN HOME|MULTI-?FAMILY|DUPLEX'; 13541 var priceRe="From the \\$[\\d,]+'?s|From \\$[\\d,]+|Starting (?:at|in|in the) \\$[\\d,'s]+|\\$[\\d,]+'?s|Limited Lots|Coming Soon|Sold Out|Now Selling|Call for [Pp]ricing"; 13542 var re=new RegExp("(?:([^\\n]*(?:limited.?time|incentive|toward|buydown|%\\s*(?:interest|rate)|design options)[^\\n]*)\\n)?([^\\n]+?)[\\n ]+("+typeRe+")[\\n ]+(.+?,\\s*[A-Z]{2},?\\s*\\d{5}(?:/\\d{5})?)[\\n ]+("+priceRe+")","gi"); 13543 var out=[], m; 13544 while((m=re.exec(norm))){ 13545 var promo=(m[1]||'').trim(), name=(m[2]||'').trim(), type=(m[3]||'').trim(), cityBlob=(m[4]||'').trim(), price=(m[5]||'').trim(); 13546 if(!name || name.length>60) continue; 13547 var cm=cityBlob.match(/^(.+?),\s*([A-Z]{2})/); var city=cm?cm[1].trim():cityBlob, state=cm?cm[2]:''; 13548 out.push({ builder:builderHint||'', community:name, city:city, state:state, home_types:type, price_text:price, promo:promo, url:urlBySlug[_slug(name)]||'', status:/sold out/i.test(price)?'sold_out':(/coming soon/i.test(price)?'coming_soon':'active') }); 13549 } 13550 if(out.length) return out; 13551 // Format B: label-based (e.g. Ivory) â Name / Location: / City: / Homes Starting at:$XXXk|M / [View]. 13552 var re2=/([^\n]+)\n[ \t]*\n?[ \t]*Location:[^\n]*\n[ \t]*City:\s*([^\n]+)\n[ \t]*(?:Homes Starting at:\s*([^\n]+)|(Upcoming|Coming Soon|Sold Out))/gi, m2;
13553 while((m2=re2.exec(norm))){ 13554 var nm=(m2[1]||'').trim(), cty=(m2[2]||'').trim(), pr=(m2[3]||'').trim(), st=(m2[4]||'').trim(); 13555 if(!nm || nm.length>70) continue; 13556 out.push({ builder:builderHint||'', community:nm, city:cty, state:'UT', home_types:ncGuessType(nm), price_text:pr?('From '+pr):'', promo:'', url:urlBySlug[_slug(nm)]||'', status:/upcoming|coming/i.test(st)?'coming_soon':(/sold/i.test(st)?'sold_out':'active') }); 13557 } 13558 return out; 13559} 13560function ncPasteModal(){ 13561 var ov=document.createElement('div'); ov.className='modal-ov open'; ov.style.zIndex='1300'; 13562 var m=document.createElement('div'); m.className='modal'; m.style.maxWidth='620px'; 13563 var h=document.createElement('div'); h.style.cssText='display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border);'; 13564 h.innerHTML='<div style="font-weight:700;font-size:17px;">Paste from a builder page</div>'; 13565 var x=document.createElement('button'); x.textContent='â'; x.style.cssText='background:none;border:none;font-size:18px;color:var(--text3);cursor:pointer;'; x.addEventListener('click',function(){ document.body.removeChild(ov); }); h.appendChild(x); m.appendChild(h); 13566 var body=document.createElement('div'); body.style.cssText='padding:16px 20px;display:flex;flex-direction:column;gap:10px;'; 13567 body.appendChild(mkDivSafe('font-size:12px;color:var(--text3);line-height:1.5;','Open a builderâs communities / available-homes page in your browser, select all (Ctrl+A), copy (Ctrl+C), and paste below. Pick the builder from the list so this update attaches to the same group you already have â retyping a name (e.g. âDR Hortonâ vs âD.R. Hortonâ) would create a duplicate group instead.')); 13568 var bl=document.createElement('label'); bl.className='fl'; bl.textContent='Builder'; body.appendChild(bl); 13569 // Builder picker: existing builders first (verbatim, so re-pastes match), then known builders not yet 13570 // present, then a "+ New builderâ¦" option that reveals a text box. Lennar and D.R. Horton pages are 13571 // auto-detected and override this selection, so it can't misfile them. 13572 var _seen={}, _builders=[]; 13573 (NC||[]).forEach(function(r){ var b=(r.builder||'').trim(); if(b && !_seen[b.toLowerCase()]){ _seen[b.toLowerCase()]=1; _builders.push(b); } }); 13574 ['D.R. Horton','Edge Homes','Fieldstone Homes','Ivory Homes','Lennar','Richmond American Homes'].forEach(function(b){ if(!_seen[b.toLowerCase()]){ _seen[b.toLowerCase()]=1; _builders.push(b); } }); 13575 _builders.sort(function(a,b){ return a.toLowerCase().localeCompare(b.toLowerCase()); }); 13576 var bin=document.createElement('select'); bin.className='fi'; 13577 var o0=document.createElement('option'); o0.value=''; o0.textContent='â Select builder â'; bin.appendChild(o0); 13578 _builders.forEach(function(b){ var o=document.createElement('option'); o.value=b; o.textContent=b; bin.appendChild(o); }); 13579 var oNew=document.createElement('option'); oNew.value='__new__'; oNew.textContent='+ New builderâ¦'; bin.appendChild(oNew); 13580 body.appendChild(bin); 13581 var newBin=document.createElement('input'); newBin.className='fi'; newBin.placeholder='New builder name (e.g. Holmes Homes)'; newBin.style.display='none'; body.appendChild(newBin); 13582 bin.addEventListener('change',function(){ newBin.style.display = (bin.value==='__new__') ? '' : 'none'; if(bin.value==='__new__') newBin.focus(); }); 13583 var tl=document.createElement('label'); tl.className='fl'; tl.textContent='Pasted page text'; body.appendChild(tl); 13584 var ta=document.createElement('textarea'); ta.className='fi'; ta.rows=9; ta.placeholder='Paste the page hereâ¦'; body.appendChild(ta); 13585 m.appendChild(body); 13586 var ft=document.createElement('div'); ft.style.cssText='display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--border);'; 13587 var cancel=document.createElement('button'); cancel.className='btn btn-g'; cancel.textContent='Cancel'; cancel.addEventListener('click',function(){ document.body.removeChild(ov); }); 13588 var go=document.createElement('button'); go.className='btn btn-p'; go.textContent='Extract & add'; 13589 go.addEventListener('click',function(){ 13590 var text=(ta.value||'').trim(); if(text.length<40){ alert('Paste the page text first.'); return; } 13591 var builderHint=(bin.value==='__new__'?newBin.value:bin.value).trim(); 13592 // Lennar/D.R. Horton set their own builder from the page; everything else needs a picked builder so 13593 // the import doesn't land in an "(Unspecified)" group. 13594 var _autoDetected=/lennar\.com\/new-homes\/|drhorton\.com\/|richmondamerican\.com\/|fieldstonehomes\.com/i.test(text); 13595 if(!_autoDetected && !builderHint){ alert('Pick a builder from the list first (or cho
13595ose â+ New builderâ¦â), so these communities attach to the right group.'); return; } 13596 var orig=go.textContent; go.textContent='Readingâ¦'; go.disabled=true; 13597 (async function(){ 13598 try{ 13599 // 1) Fast local parse of the copied listing format. 2) AI fallback for anything it can't read. 13600 // Lennar lists individual homes, not communities, so it gets its own URL-based parser first. 13601 var _pick=builderHint.toLowerCase(); 13602 var arr = /lennar\.com\/new-homes\//i.test(text) ? ncParseLennar(text) 13603 : /drhorton\.com\//i.test(text) ? ncParseDRHorton(text) 13604 : /richmondamerican\.com\//i.test(text) ? ncParseRichmond(text) 13605 : (/fieldstonehomes\.com/i.test(text) || /fieldstone/.test(_pick)) ? ncParseFieldstone(text) 13606 : []; 13607 if(!arr.length) arr=ncLocalParse(text, builderHint); 13608 if(!arr.length){ 13609 var corpus=(builderHint?('Builder: '+builderHint+'\n'):'')+text; 13610 var r=await ncExtractFromText(corpus); arr=r.arr; 13611 if(!arr.length){ go.textContent=orig; go.disabled=false; alert('Nothing recognizable was found.\n\nMake sure you copied the page that lists the communities with prices (not the homepage). Diagnostic:\n\n'+String(r.raw||'(empty response)').slice(0,400)); return; } 13612 } 13613 var res=ncApplyExtracted(arr,'paste'); 13614 document.body.removeChild(ov); renderNewConstruction(); 13615 var msg='Imported into New Construction:\n\n⢠'+res.added+' new communit'+(res.added===1?'y':'ies')+' added'; 13616 if(res.updated){ msg+='\n⢠'+res.updated+' row'+(res.updated===1?'':'s')+' matched a community already on the list (updated in place, or a duplicate/individual home under the same community).\n\nNote: one record is kept per community â not per home or floor plan â so builder pages that list many homes will collapse down to their community count.'; } 13617 alert(msg); 13618 }catch(e){ go.textContent=orig; go.disabled=false; alert('Extract failed: '+(e&&e.message||e)); } 13619 })(); 13620 }); 13621 ft.appendChild(cancel); ft.appendChild(go); m.appendChild(ft); 13622 ov.appendChild(m); ov.addEventListener('click',function(e){ if(e.target===ov) document.body.removeChild(ov); }); 13623 document.body.appendChild(ov); setTimeout(function(){ try{ ta.focus(); }catch(e){} },50); 13624} 13625async function scanBuilderEmails(btn){ 13626 await loadNcBuilders(); 13627 if(!NC_BUILDERS.length){ if(confirm('No builders set up yet. Add your builders and their email senders first?')) ncBuildersModal(); return; } 13628 var memberId=(typeof gmailState!=='undefined' && (gmailState.activeMemberId || Object.keys(gmailState.connectedAccounts||{})[0])); 13629 if(!memberId){ alert('Connect a Gmail account first (open the Gmail tab), then try again.'); return; } 13630 var senders=[]; NC_BUILDERS.forEach(function(b){ String(b.senders||'').split(/[,\n|]+/).map(function(s){return s.trim();}).filter(Boolean).forEach(function(s){ senders.push('from:'+s); }); }); 13631 if(!senders.length){ alert('Your builders have no email senders yet. Add them in Manage builders.'); return; } 13632 var q='('+senders.join(' OR ')+') newer_than:30d'; 13633 var orig=btn?btn.textContent:''; if(btn){ btn.textContent='Scanningâ¦'; btn.disabled=true; } 13634 var done=function(){ if(btn){ btn.textContent=orig; btn.disabled=false; } }; 13635 try{ 13636 var listResp=await fetch('/api/gmail-api',{ method:'POST', headers:await apiHeaders(), body:JSON.stringify({ action:'inbox', memberId:memberId, query:q }) }); 13637 var listData=await listResp.json(); if(listData.error) throw new Error(listData.error); 13638 var msgs=listData.messages||[]; 13639 if(!msgs.length){ done(); alert('No builder emails found in the last 30 days from those senders. Double-check the sender addresses in Manage builders, and that youâre subscribed.'); return; } 13640 var threadIds=[]; msgs.forEach(function(mm){ if(mm.threadId && threadIds.indexOf(mm.threadId)<0) threadIds.push(mm.threadId); }); 13641 threadIds=threadIds.slice(0,12); 13642 var chunks=[]; 13643 for(var i=0;i<threadIds.length;i++){
13644 try{ var tR=await fetch('/api/gmail-api',{ method:'POST', headers:await apiHeaders(), body:JSON.stringify({ action:'thread', memberId:memberId, threadId:threadIds[i] }) }); 13645 var tD=await tR.json(); (tD.messages||[]).forEach(function(mm){ var t=(mm.bodyText||'').replace(/\r/g,'').trim(); if(t) chunks.push('From: '+(mm.from||'')+'\nSubject: '+(mm.subject||'')+'\n'+t.slice(0,4000)); }); 13646 }catch(e){} 13647 } 13648 var corpus=chunks.join('\n\n---\n\n'); 13649 if(!corpus){ done(); alert('Found builder emails but couldnât read their contents.'); return; } 13650 var ex=await ncExtractFromText(corpus); var arr=ex.arr; 13651 var res=ncApplyExtracted(arr,'email'); var added=res.added, updated=res.updated; 13652 done(); 13653 renderNewConstruction(); 13654 alert('Scan complete.\n\n'+added+' new communit'+(added===1?'y':'ies')+' added, '+updated+' updated.'+(arr.length===0?'\n\n(No communities were found in those emails â they may have been generic newsletters.)':'')); 13655 }catch(e){ done(); alert('Scan failed: '+(e&&e.message||e)); } 13656} 13657// ============================================================================ 13658// LETTER OF INTENT (LOI) â commercial lease, tenant-rep. Deterministic template: you fill the 13659// variables + toggle optional clauses; the legal boilerplate below is fixed word-for-word. 13660// ============================================================================ 13661function _numWord(n){ var w=['zero','one','two','three','four','five','six','seven','eight','nine','ten','eleven','twelve','thirteen','fourteen','fifteen','sixteen','seventeen','eighteen','nineteen','twenty']; n=parseInt(n); return (n>=0&&n<=20)?w[n]:String(n); } 13662function _nw(n){ var s=_numWord(n); return s.charAt(0).toUpperCase()+s.slice(1)+' ('+(parseInt(n)||0)+')'; } // "Five (5)" 13663function _loiM(n){ n=dsNum(n); return '$'+n.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2}); } 13664function loiComputed(d){ 13665 var sqft=dsNum(d.sqft), perSF=dsNum(d.baseRentPerSF), term=parseInt(d.termYears)||0; 13666 var monthly, annual; 13667 if(perSF>0 && sqft>0){ annual=perSF*sqft; monthly=annual/12; } 13668 else { monthly=dsNum(d.baseRent); annual=monthly*12; if(!perSF && sqft>0) perSF=annual/sqft; } 13669 var camLo=dsNum(d.camLowPerSF), camHi=dsNum(d.camHighPerSF), hasCamRange=(camLo>0||camHi>0)&&sqft>0; 13670 var camLoAnn=camLo*sqft, camHiAnn=camHi*sqft, camLoMo=camLoAnn/12, camHiMo=camHiAnn/12; 13671 return { 13672 sqft:sqft, perSF:perSF, monthly:monthly, annual:annual, term:term, 13673 abatementTotal:monthly*(parseInt(d.abatementMonths)||0), 13674 commissionAgg:monthly*12*term*(dsNum(d.commissionPct)/100), 13675 hasCamRange:hasCamRange, camLoMo:camLoMo, camHiMo:camHiMo, camLoAnn:camLoAnn, camHiAnn:camHiAnn, 13676 totLoMo:monthly+camLoMo, totHiMo:monthly+camHiMo, totLoAnn:annual+camLoAnn, totHiAnn:annual+camHiAnn 13677 }; 13678} 13679function loiDefault(){ return { id:Date.now()+Math.floor(Math.random()*100000), contact_id:null, name:'', data:{ 13680 date:tod(), address:'', toName:'', toBrokerage:'', toRole:'Listing Agent', 13681 fromLine:"Elda & Randy Baker, Palacios Baker Real Estate, as Tenant's representatives", 13682 tenant:'', sqft:'', permittedUse:'', termYears:5, baseRent:'', baseRentPerSF:'', nnnEstimate:'', 13683 camLowPerSF:'', camHighPerSF:'', ddOn:true, ddDays:45, rentCommence:'', 13684 securityDeposit:'', commissionPct:3, commissionMin:'', brokerageRationaleOn:false, 13685 brokerageRationale:"Palacios Baker Real Estate is independently managing zoning verification, conditional-use analysis, and Certificate of Occupancy confirmation on Tenant's behalf â diligence that reduces Landlord's risk, streamlines approvals, and is work that would customarily fall to Landlord's side of the transaction or be reflected in a higher co-op commission split.", 13686 expireDate:'', expireTime:'5:00 p.m. Mountain Time', 13687 sigEntity:'', sigName:'', sigTitle:'', preparedBy:'Elda Baker', preparedByBrokerage:'Palacios Baker Real Estate', 13688 abatementOn:true, abatementMonths:6, tiOn:true, tiAmount:'', 13689 renewalOn:true, renewalCount:1, renewalMin:3, renewalMax:5, parkingOn:true, signageOn:true, 13690 exclusivityOn:false, exclusivityText:"Landlord agrees that during the initial lease term and any renew
13690al period, no other tenant in the shopping center/plaza will be permitted to operate a business substantially similar to Tenant's event venue business or otherwise directly compete with Tenant's primary use.", 13691 subleasingOn:false, subleasingText:"Tenant may sublease or assign the Premises with Landlord's prior written consent, which shall not be unreasonably withheld, conditioned, or delayed." } }; } 13692function loiLetterHTML(d){ 13693 var c=loiComputed(d); var rent=c.monthly; 13694 var sec = (d.securityDeposit!=='' && d.securityDeposit!=null) ? dsNum(d.securityDeposit) : rent; 13695 var tiMonthsN = d.abatementOn ? (parseInt(d.abatementMonths)||6) : 6; 13696 var ti = (d.tiAmount!=='' && d.tiAmount!=null) ? dsNum(d.tiAmount) : rent*tiMonthsN; 13697 var rows=''; 13698 function row(k,v){ rows+='<tr><td class="k">'+_esc(k)+'</td><td class="v">'+v+'</td></tr>'; } 13699 row('Permitted Use', _esc(d.permittedUse||'')); 13700 row('Lease Term', _nw(d.termYears)+' years, commencing on the Rent Commencement Date.'); 13701 row('Base Rent', (c.perSF>0 ? (_loiM(c.perSF)+'/SF annually ('+_loiM(rent)+' per month; '+_loiM(c.annual)+' annually)') : (_loiM(rent)+' per month ('+_loiM(c.annual)+' annually)'))+', triple net (NNN), subject to verification of the rentable area and agreed lease terms.'); 13702 if(c.hasCamRange){ 13703 row('CAM Expenses', 'CAM is estimated at '+_loiM(dsNum(d.camLowPerSF))+'–'+_loiM(dsNum(d.camHighPerSF))+'/SF annually, or approximately '+_loiM(c.camLoMo)+'–'+_loiM(c.camHiMo)+' per month ('+_loiM(c.camLoAnn)+'–'+_loiM(c.camHiAnn)+' annually), subject to annual reconciliation and adjustment. Landlord will provide the current CAM/NNN budget, prior two years of reconciliations (if available), the allocation methodology, and a confirmed estimate of Tenant’s monthly share before lease execution.'); 13704 row('Estimated Total Occupancy Cost', 'Approximately '+_loiM(c.totLoMo)+'–'+_loiM(c.totHiMo)+' per month ('+_loiM(c.totLoAnn)+'–'+_loiM(c.totHiAnn)+' annually), combining Base Rent and estimated CAM range, before separately metered utilities.'); 13705 } else { 13706 row('NNN Expenses', "Tenant will pay its proportionate share of taxes, insurance, and common-area operating expenses. The current estimated NNN/CAM charge is approximately "+_loiM(dsNum(d.nnnEstimate))+" per month, subject to annual reconciliation and adjustment. Landlord will provide the current NNN/CAM budget, prior two years of reconciliations (if available), the allocation methodology, and an estimate of Tenant’s monthly share before lease execution."); 13707 } 13708 row('Utilities / Services', "Tenant will pay separately metered utilities and services used by the Premises. Responsibility for HVAC maintenance/replacement, structural components, roof, parking areas, landscaping, snow removal, and other building services will be specifically allocated in the lease."); 13709 row('Delivery Condition', "Premises will be delivered in its existing full build-out condition, broom-clean, free of occupants and personal property, with all building systems serving the Premises in good working order, and compliant with applicable laws for the agreed use, subject to Tenant’s inspection and approval."); 13710 row('Rent Commencement', "The later of "+_esc(d.rentCommence||'')+", or Landlord’s delivery of the Premises in the required condition, following satisfaction or waiver of Tenant’s contingencies."); 13711 if(d.ddOn) row('Due Diligence Period', 'Tenant will have '+(parseInt(d.ddDays)||45)+' days from full execution of this LOI (the “Due Diligence Period”) to complete, at Tenant’s expense, all zoning verification, conditional-use approval, parking-ratio confirmation, and Certificate of Occupancy requirements with the applicable municipality for Tenant’s intended use. Landlord will reasonably cooperate with and, where required, co-sign or authorize Tenant’s permit and approval applications as property owner, at no cost to Landlord. If Tenant is unable to obtain confirmation that its intended use is permitted, and that a Certificate of Occupancy for that use is obtainable, on terms acceptable to Tenant within the Due Diligence Period, Tenant may terminate this LOI and any resulting lease negotiations by written notice to Landlord, without further obligation to either party. No definitive lease will be executed prior to satisfaction or written waiver of this contingency.'); 13712 if(d.abatementOn) row('Rent Abatement', "The first "+_nw(d.abatementMonths)+" months of base rent will be fully abated, totaling "+_loiM(c.abatementTotal)+" at the stated initial base rent. Tenant will remain responsible for NNN expenses and separately metered utilities during the abatement period. This abatement is separate from the tenant-improvement allowance described below."); 13713 if(d.tiOn) row('Tenant Improvements', "Landlord will provide a tenant-improvement allowance of "+_loiM(ti)+", equivalent to "+_numWord(tiMonthsN)+" months of the stated base rent. The allowance may be applied to mutually approved design, permitting, construction, fixtures, cabling, signage, accessibility, and other improvements to the Premises. The lease will specify the approval, documentation, draw, reimbursement, and final-disbursement process and deadlines."); 13714 row('Security Deposit', "A security deposit of "+_loiM(sec)+", equal to one month’s base rent, will be paid upon execution of the lease together with any other amounts then due. Any first month’s base-rent payment required at signing will be credited and applied consistently with the base-rent abatement stated above."); 13715 if(d.renewalOn) row('Renewal Option', "Tenant will have "+_nw(d.renewalCount)+" option to renew the lease for an additional term of not less than "+_nw(d.renewalMin)+" years and not more than "+_nw(d.renewalMax)+" years, as selected by Tenant in its renewal notice, at the then-current fair market base rent. The lease will establish the notice period and a reasonable process for determining fair market rent.");
13716 if(d.parkingOn) row('Parking / Access', "Tenant and its employees, clients, and invitees will have nonexclusive use of the property’s parking areas and 24-hour access to the Premises, subject to reasonable rules that do not materially interfere with Tenant’s use."); 13717 if(d.signageOn) row('Signage', "Tenant may install code-compliant building, storefront, monument/panel, and door signage, subject to Landlord’s reasonable approval and applicable governmental requirements."); 13718 var extra=''; 13719 if(d.exclusivityOn || d.subleasingOn){ 13720 extra='<div class="loi-h">Requested Additional Lease Provisions</div>'; 13721 if(d.exclusivityOn) extra+='<p><b>Exclusivity:</b> '+_esc(d.exclusivityText||'')+'</p>'; 13722 if(d.subleasingOn) extra+='<p><b>Subleasing:</b> '+_esc(d.subleasingText||'')+'</p>'; 13723 } 13724 var pct=dsNum(d.commissionPct), cmin=dsNum(d.commissionMin); 13725 var commClause=_esc(_numWord(pct))+' percent ('+pct+'%) of the aggregate base rent payable during the initial '+_esc(_numWord(d.termYears))+'-year lease term'+(cmin>0?(', or '+_loiM(cmin)+', whichever is greater'):''); 13726 var brokRationale=(d.brokerageRationaleOn && d.brokerageRationale)?(' '+_esc(d.brokerageRationale)):''; 13727 return '' 13728 +'<div class="loi-head">' 13729 +'<img class="loi-crest" src="pbre-loi-logo.png" alt="Palacios Baker Real Estate">' 13730 +'<div class="loi-brand">PALACIOS BAKER REAL ESTATE</div>' 13731 +'<div class="loi-sub">NON-BINDING LOI • '+_esc(dsLongDate(d.date)||d.date||'')+'</div>' 13732 +'</div>' 13733 +'<div class="loi-title">Letter of Intent to Lease</div>' 13734 +'<div class="loi-addr">'+_esc(d.address||'')+'</div>' 13735 +(d.sqft?('<div class="loi-sqft">Approximately '+_esc(d.sqft)+' rentable square feet</div>'):'') 13736 +'<table class="loi-meta">' 13737 +'<tr><td class="k">Date</td><td class="v">'+_esc(dsLongDate(d.date)||d.date||'')+'</td></tr>' 13738 +'<tr><td class="k">To</td><td class="v">'+_esc([d.toName,d.toBrokerage,d.toRole].filter(Boolean).join(', '))+'</td></tr>' 13739 +'<tr><td class="k">From</td><td class="v">'+_esc(d.fromLine||'')+'</td></tr>' 13740 +'<tr><td class="k">Proposed Tenant</td><td class="v">'+_esc(d.tenant||'')+'</td></tr>' 13741 +'<tr><td class="k">Property</td><td class="v">'+_esc(d.address||'')+' (the “Premises”), consisting of approximately '+_esc(d.sqft||'')+' rentable square feet.</td></tr>' 13742 +'</table>' 13743 +'<p class="loi-intro">The proposed tenant submits this non-binding Letter of Intent (“LOI”) as a basis for negotiating a mutually acceptable lease. Except for the provisions expressly stated as binding, neither party will be obligated unless and until a definitive lease is negotiated, approved, executed, and delivered by both parties.</p>' 13744 +'<div class="loi-h">Proposed Business Terms</div>' 13745 +'<table class="loi-terms">'+rows+'</table>' 13746 +'<div class="loi-h">Tenant Contingencies</div>' 13747 +'<ul class="loi-ul">' 13748 +'<li>Tenant’s confirmation that its intended use is permitted by applicable zoning and that all required business, conditional-use, occupancy, health, fire, or other governmental approvals can be obtained on terms acceptable to Tenant.</li>' 13749 +'<li>Tenant’s satisfactory inspection of the Premises and building systems, review of plans and measurements, and confirmation of ADA/accessibility requirements applicable to Tenant’s use.</li>' 13750 +'<li>Tenant’s review and approval of the proposed lease, title/ownership information, property rules, insurance requirements, and all NNN/CAM documentation and estimates.</li>' 13751 +'<li>Tenant’s receipt of any required internal, lender, franchise, partner, or legal approvals.</li>' 13752 +'</ul>' 13753 +'<div class="loi-h">Additional Provisions</div>' 13754 +'<p><b>Lease Documentation.</b> Landlord will prepare the initial lease draft. Each party may review the lease with its own legal and tax advisers.</p>' 13755 +'<p><b>Brokerage.</b> Landlord acknowledges that Elda Baker and Randy Baker of Palacios Baker Real Estate represent the proposed tenant.'+brokRationale+' Landlord or Landlord’s brokerage will pay Palacios Baker Real Estate a commission equal to '+commClause+'. The commission will be earned and payable pursuant to a separate written commission agreement executed by the applicable parties, and will not be contingent on Tenant’s continued occupancy after lease commencement.</p>' 13756 +'<p><b>Confidentiality and Costs.</b> Unless otherwise agreed in writing, each party will bear its own costs. The parties will use reasonable discretion regarding nonpublic financial and business information exchanged during negotiations.
13756</p>' 13757 +'<p><b>Non-Binding Effect.</b> This LOI is intended only as an expression of interest and a framework for negotiation. No leasehold interest is created. Neither party is bound unless a definitive lease is fully executed and delivered. Any binding confidentiality, access, or brokerage obligations must be stated in a separate signed agreement.</p>' 13758 +'<p><b>Expiration.</b> This proposal will expire at '+_esc(d.expireTime||'')+' on '+_esc(dsLongDate(d.expireDate)||d.expireDate||'')+', unless extended in writing by the proposed tenant.</p>' 13759 +'<p>If these terms are acceptable as a basis for lease preparation, please acknowledge below. We look forward to working toward a successful agreement.</p>' 13760 +'<table class="loi-sig"><tr>' 13761 +'<td><div class="sh">PROPOSED TENANT</div><div class="sl">By: _______________________</div><div class="sn">'+_esc(d.sigEntity||d.tenant||'')+'</div><div class="sl">Name: ____________________</div><div class="sn">'+_esc(d.sigName||'')+'</div><div class="sl">Title: _____________________</div><div class="sn">'+_esc(d.sigTitle||'')+'</div><div class="sl">Date: _____________________</div></td>' 13762 +'<td><div class="sh">ACKNOWLEDGED BY LANDLORD</div><div class="sl">By: _______________________</div><div class="sn"> </div><div class="sl">Name: ____________________</div><div class="sn"> </div><div class="sl">Title: _____________________</div><div class="sn"> </div><div class="sl">Date: _____________________</div></td>' 13763 +'</tr></table>' 13764 +extra 13765 +'<div class="loi-h">Prepared By</div>' 13766 +'<div class="sn">'+_esc(d.preparedBy||'')+'</div>' 13767 +'<div class="loi-pbline">'+_esc(d.preparedByBrokerage||'')+'</div>' 13768 +'<div class="loi-pbline">Tenant’s Representative / REALTOR®</div>' 13769 +'<div class="sl">Signature: _______________________</div>' 13770 +'<div class="sl">Date: _______________________</div>' 13771 +'<div class="loi-foot">Palacios Baker Real Estate · Utah’s Wise Choice</div>'; 13772} 13773function _loiStyle(){ 13774 if(ge('loiCss')) return; var s=document.createElement('style'); s.id='loiCss'; 13775 s.textContent= 13776 '#loiSheet{background:#fff;color:#1a1a1a;max-width:820px;margin:0 auto;padding:34px 40px;font-family:Georgia,\'Times New Roman\',serif;font-size:12.5px;line-height:1.5;border:1px solid #ddd;}' 13777 +'#loiSheet .loi-head{text-align:center;margin:0 0 12px;padding-bottom:10px;border-bottom:2px solid #b8912f;}' 13778 +'#loiSheet .loi-crest{height:96px;width:auto;display:block;margin:0 auto 4px;}' 13779 +'#loiSheet .loi-brand{font-size:15px;font-weight:700;letter-spacing:2.5px;color:#1a1a1a;}' 13780 +'#loiSheet .loi-sub{font-size:10.5px;letter-spacing:1.5px;color:#8a6d1a;text-transform:uppercase;margin-top:3px;}' 13781 +'#loiSheet .loi-title{font-size:24px;font-weight:700;margin:2px 0 2px;text-align:center;}' 13782 +'#loiSheet .loi-addr{font-size:13px;color:#333;text-align:center;}' 13783 +'#loiSheet .loi-sqft{font-size:12px;color:#555;font-style:italic;text-align:center;margin-bottom:14px;}' 13784 +'#loiSheet .loi-foot{text-align:center;font-size:10px;color:#999;margin-top:24px;border-top:1px solid #e5e5e5;padding-top:8px;}' 13785 +'#loiSheet table.loi-meta{width:100%;border-collapse:collapse;margin-bottom:12px;}' 13786 +'#loiSheet table.loi-meta td.k{font-weight:700;width:150px;vertical-align:top;padding:2px 8px 2px 0;}' 13787 +'#loiSheet table.loi-meta td.v{vertical-align:top;padding:2px 0;}' 13788 +'#loiSheet .loi-intro{font-size:11.5px;color:#333;}' 13789 +'#loiSheet .loi-h{font-size:14px;font-weight:700;margin:14px 0 6px;border-bottom:1px solid #ccc;padding-bottom:2px;}' 13790 +'#loiSheet table.loi-terms{width:100%;border-collapse:collapse;}' 13791 +'#loiSheet table.loi-terms td.k{font-weight:700;width:170px;vertical-align:top;padding:5px 10px 5px 0;}' 13792 +'#loiSheet table.loi-terms td.v{vertical-align:top;padding:5px 0;border-bottom:1px solid #f0f0f0;}' 13793 +'#loiSheet ul.loi-ul{margin:4px 0;padding-left:18px;} #loiSheet ul.loi-ul li{margin:3px 0;}' 13794 +'#loiSheet p{margin:6px 0;}' 13795 +'#loiSheet table.loi-sig{width:100%;margin-top:16px;} #loiSheet table.loi-sig td{width:50%;vertical-align:top;padding-right:16px;}' 13796 +'#loiSheet .sh{font-weight:700;font-size:11px;letter-spacing:.5px;margin-bottom:8px;} #loiSheet .sl{margin-top:10px;color:#333;} #loiSheet .sn{font-weight:600;margin-top:1px;}' 13797 +'#loiSheet .loi-pbline{color:#333;}' 13798 +'#page-loi .loibtn{background:var(--accent);color:#fff;border:none;border-radius:7px;padding:7px 14px;font-family:inherit;font-size:14px;cursor:pointer;}' 13799 +'#page-loi .loibtn.g{background:transparent;border:1px solid var(--border);color:var(--text);}' 13800 +'#page-loi .loi-form{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:14px;margin-bottom:16px;}' 13801 +'#page-loi .loi-form label{font-size:12px;color:var(--text3);display:block;margin-bottom:3px;}' 13802 +'#page-loi .loi-form input,#page-loi .loi-form textarea{width:100%;box-sizing:border-box;background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-family:inherit;font-size:13px;}' 13803 +'#page-loi .loi-tg{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text);}'; 13804 document.head.appendChild(s); 13805} 13806var _loiView='active', _loiQuery=''; 13807function loiArchived(l){ return !!(l.data&&l.data.archived); } 13808function setLoiArchived(l,val){ if(!l.data) l.data={}; l.data.archived=val; saveLoi(l); } 13809function renderLois(){ 13810 _loiStyle(); var root=ge('loiRoot'); if(!root) return; root.innerHTML='';
13811 var bar=document.createElement('div'); bar.style.cssText='display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;'; 13812 bar.appendChild(mkDivSafe('font-size:22px;font-weight:600;','Letters of Intent')); 13813 var nb=document.createElement('button'); nb.className='loibtn'; nb.textContent='+ New LOI'; nb.addEventListener('click',function(){ openLoi(loiDefault(), true); }); bar.appendChild(nb); root.appendChild(bar); 13814 if(!LOIS.length){ root.appendChild(mkDivSafe('color:var(--text3);padding:20px 0;','No letters yet. Create one, fill the fields, and export a PDF.')); return; } 13815 var ctr=document.createElement('div'); ctr.style.cssText='display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px;'; 13816 var search=document.createElement('input'); search.type='text'; search.placeholder='Search LOIs (name, tenant, address)â¦'; search.value=_loiQuery; 13817 search.style.cssText='flex:1;min-width:200px;background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:7px 10px;font-family:inherit;font-size:13px;'; 13818 var sel=document.createElement('select'); sel.style.cssText='background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:7px 8px;font-family:inherit;font-size:13px;width:auto;'; 13819 [['active','Active'],['archived','Archived'],['all','All']].forEach(function(o){ var op=document.createElement('option'); op.value=o[0]; op.textContent=o[1]; if(o[0]===_loiView) op.selected=true; sel.appendChild(op); }); 13820 ctr.appendChild(search); ctr.appendChild(sel); root.appendChild(ctr); 13821 var list=document.createElement('div'); list.style.cssText='display:flex;flex-direction:column;gap:8px;'; root.appendChild(list); 13822 function drawList(){ 13823 list.innerHTML=''; var q=_loiQuery.trim().toLowerCase(); 13824 var items=LOIS.filter(function(l){ 13825 var arch=loiArchived(l); 13826 if(!q){ if(_loiView==='active'&&arch) return false; if(_loiView==='archived'&&!arch) return false; return true; } 13827 var hay=((l.name||'')+' '+(l.data&&l.data.tenant||'')+' '+(l.data&&l.data.address||'')).toLowerCase(); 13828 return hay.indexOf(q)>=0; // searching spans both active + archived 13829 }).sort(function(a,b){ return String(b.updated_at||'').localeCompare(String(a.updated_at||'')); }); 13830 if(!items.length){ list.appendChild(mkDivSafe('color:var(--text3);padding:16px 0;', q?'No LOIs match your search.':(_loiView==='archived'?'No archived LOIs.':'No LOIs here.'))); return; } 13831 items.forEach(function(l){ 13832 var arch=loiArchived(l); 13833 var row=document.createElement('div'); row.style.cssText='display:flex;justify-content:space-between;align-items:center;gap:8px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:10px 14px;cursor:pointer;'+(arch?'opacity:.72;':''); 13834 var badge=arch?'<span style="font-size:10px;font-weight:700;color:var(--text3);border:1px solid var(--border);border-radius:4px;padding:1px 5px;margin-left:6px;vertical-align:middle;">ARCHIVED</span>':''; 13835 row.innerHTML='<div><div style="font-weight:600;">'+_esc(l.name||(l.data&&l.data.address)||'Untitled LOI')+badge+'</div><div style="font-size:12px;color:var(--text3);">'+_esc((l.data&&l.data.tenant||'')+(l.updated_at?(' · updated '+fd(l.updated_at)):''))+'</div></div>'; 13836 var btns=document.createElement('div'); btns.style.cssText='display:flex;gap:6px;flex-shrink:0;'; 13837 var ab=document.createElement('button'); ab.className='loibtn g'; ab.style.cssText='font-size:12px;padding:3px 9px;'; ab.textContent=arch?'Unarchive':'Archive'; 13838 ab.addEventListener('click',function(e){ e.stopPropagation(); setLoiArchived(l,!arch); drawList(); }); 13839 var del=document.createElement('button'); del.className='loibtn g'; del.style.cssText='font-size:12px;padding:3px 9px;'; del.textContent='Delete'; 13840 (function(id){ del.addEventListener('click',function(e){ e.stopPropagation(); if(confirm('Delete this LOI?')){ delLoi(id); drawList(); } }); })(l.id); 13841 btns.appendChild(ab); btns.appendChild(del); 13842 row.addEventListener('click',function(){ openLoi(l,false); }); row.appendChild(btns); list.appendChild(row); 13843 }); 13844 } 13845 search.addEventListener('input',function(){ _loiQuery=search.value; drawList(); }); 13846 sel.addEventListener('change',function(){ _loiView=sel.value; drawList(); }); 13847 drawList(); 13848} 13849function openLoi(loi, isNew){ 13850 _loiStyle(); var root=ge('loiRoot'); if(!root) return; root.innerHTML=''; var d=loi.data; 13851 var tb=document.createElement('div'); tb.style.cssText='display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px;'; 13852 var back=document.createElement('button'); back.className='loibtn g'; back.textContent='â¹ Back'; back.addEventListener('click',renderLois); tb.appendChild(back); 13853 var cWrap=document.createElement('div'); cWrap.style.cssText='min-width:240px;'; tb.appendChild(cWrap); 13854 var picker=buildContactPicker(cWrap,'loi_client','Load client (search name / address)â¦',function(){ var id=picker.hidden.value; if(!id) return; var c=gc(parseInt(id)); if(!c) return; loi.contact_id=parseInt(id); d.tenant=fn(c); d.address=c.property||c.address||d.address; if(!loi.name) loi.name=d.address||d.tenant; openLoi(loi,isNew); }); 13855 var saveB=document.createElement('button'); saveB.className='loibtn'; saveB.textContent='Save'; saveB.addEventListener('click',function(){ if(!loi.name) loi.name=d.address||d.tenant||'LOI';
13855 if(!LOIS.some(function(x){return String(x.id)===String(loi.id);})) LOIS.push(loi); saveLoi(loi); saveB.textContent='Saved â'; setTimeout(function(){saveB.textContent='Save';},1500); }); tb.appendChild(saveB); 13856 var pdfB=document.createElement('button'); pdfB.className='loibtn'; pdfB.textContent='Export PDF'; pdfB.addEventListener('click',function(){ loiExport(loi,'pdf',pdfB); }); tb.appendChild(pdfB); 13857 var docB=document.createElement('button'); docB.className='loibtn g'; docB.textContent='Export Word'; docB.addEventListener('click',function(){ loiExportWord(loi,docB); }); tb.appendChild(docB); 13858 root.appendChild(tb); 13859 13860 var preview=document.createElement('div'); preview.id='loiSheet'; 13861 function refresh(){ preview.innerHTML=loiLetterHTML(d); } 13862 var form=document.createElement('div'); form.className='loi-form'; 13863 function fld(key,label,type,ph){ var w=document.createElement('div'); var l=document.createElement('label'); l.textContent=label; w.appendChild(l); 13864 var el = type==='textarea'?document.createElement('textarea'):document.createElement('input'); if(type&&type!=='textarea')el.type=type; if(ph)el.placeholder=ph; if(type==='textarea')el.rows=2; 13865 el.value=(d[key]!=null?d[key]:''); el.addEventListener('input',function(){ d[key]=el.value; refresh(); }); w.appendChild(el); form.appendChild(w); return w; } 13866 function tgl(key,label){ var w=document.createElement('div'); var lab=document.createElement('label'); lab.className='loi-tg'; var cb=document.createElement('input'); cb.type='checkbox'; cb.checked=!!d[key]; cb.addEventListener('change',function(){ d[key]=cb.checked; refresh(); }); lab.appendChild(cb); lab.appendChild(document.createTextNode(label)); w.appendChild(lab); form.appendChild(w); return w; } 13867 fld('date','Date','date'); fld('address','Lease / Property address','text','address of the lease'); 13868 fld('toName','Recipient name'); fld('toBrokerage','Recipient brokerage'); fld('toRole','Recipient role'); 13869 fld('tenant','Proposed tenant'); fld('sqft','Rentable square feet'); 13870 fld('permittedUse','Permitted use','textarea'); 13871 fld('termYears','Lease term (years)','number'); fld('baseRent','Base rent ($/month)','number'); fld('baseRentPerSF','Base rent ($/SF/yr, optional â overrides monthly)','number'); 13872 fld('camLowPerSF','CAM low ($/SF/yr, optional)','number'); fld('camHighPerSF','CAM high ($/SF/yr, optional)','number'); fld('nnnEstimate','NNN estimate ($/month, if no CAM range)','number'); 13873 fld('rentCommence','Rent commencement (e.g. August 1, 2026)'); 13874 tgl('ddOn','Due Diligence Period'); fld('ddDays','Due diligence days','number'); 13875 fld('securityDeposit','Security deposit ($, blank = 1 month)','number'); 13876 fld('commissionPct','Commission %','number'); fld('commissionMin','Commission floor ($, optional)','number'); 13877 tgl('brokerageRationaleOn','Brokerage rationale'); fld('brokerageRationale','Brokerage rationale text','textarea'); 13878 fld('expireDate','Expiration date','date'); fld('expireTime','Expiration time'); 13879 fld('sigEntity','Tenant entity (signature)'); fld('sigName','Tenant signer name'); fld('sigTitle','Tenant signer title'); 13880 // toggles + their sub-values 13881 tgl('abatementOn','Rent Abatement'); fld('abatementMonths','Abatement months','number'); 13882 tgl('tiOn','Tenant Improvements'); fld('tiAmount','TI allowance ($, blank = monthsÃrent)','number'); 13883 tgl('renewalOn','Renewal Option'); fld('renewalCount','Renewal options (count)','number'); fld('renewalMin','Renewal min years','number'); fld('renewalMax','Renewal max years','number'); 13884 tgl('parkingOn','Parking / Access'); tgl('signageOn','Signage'); 13885 tgl('exclusivityOn','Exclusivity'); fld('exclusivityText','Exclusivity text','textarea'); 13886 tgl('subleasingOn','Subleasing'); fld('subleasingText','Subleasing text','textarea'); 13887 root.appendChild(form); 13888 root.appendChild(preview); refresh(); 13889} 13890function _docCanvas(id){ 13891 return dsLoadScript('https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js').then(function(){ 13892 var node=ge(id); var pw=node.style.width, pm=node.style.maxWidth; node.style.width='800px'; node.style.maxWidth='800px'; 13893 var restore=function(){ node.style.width=pw; node.style.maxWidth=pm; }; 13894 return window.html2canvas(node,{scale:2,backgroundColor:'#ffffff',useCORS:true,windowWidth:840,width:800}
13894).then(function(c){ restore(); return c; }, function(e){ restore(); throw e; }); 13895 }); 13896} 13897// Ask for a file name (spaces allowed; only characters illegal in file names are stripped). 13898// Returns the chosen base name, or null if the user cancelled. 13899function loiAskName(loi){ 13900 var suggested=(loi.name||(loi.data&&loi.data.address)||'Letter of Intent'); 13901 var v=window.prompt('Name this file (no extension):', suggested); 13902 if(v===null) return null; 13903 v=v.replace(/[\\\/:*?"<>|]+/g,'').replace(/\s+/g,' ').trim(); 13904 return v||suggested; 13905} 13906// Export the letter as an editable Word document (HTML .doc that Word opens and edits). Text-based, so 13907// it stays a real editable document; the crest is dropped and a text masthead is used instead. 13908function loiExportWord(loi, btn){ 13909 var name=loiAskName(loi); if(name===null) return; 13910 if(btn){ btn.textContent='Buildingâ¦'; btn.disabled=true; } 13911 var done=function(){ if(btn){ btn.textContent='Export Word'; btn.disabled=false; } }; 13912 var body=loiLetterHTML(loi.data); 13913 // Word export: drop the crest image (embedded images render unreliably) and keep the text masthead. 13914 body = body.replace(/<img class="loi-crest"[^>]*>/, ''); 13915 (function(){ 13916 var css='<style>' 13917 +'body{font-family:Georgia,\'Times New Roman\',serif;font-size:11pt;color:#000;}' 13918 +'.loi-head{text-align:center;border-bottom:2px solid #b8912f;padding-bottom:6pt;margin-bottom:10pt;}' 13919 +'.loi-brand{font-size:14pt;font-weight:bold;letter-spacing:2pt;color:#000;}' 13920 +'.loi-sub{font-size:9pt;letter-spacing:1pt;color:#8a6d1a;}' 13921 +'.loi-title{font-size:20pt;font-weight:bold;margin:2pt 0;text-align:center;}' 13922 +'.loi-addr{font-size:11pt;text-align:center;}' 13923 +'.loi-sqft{font-size:10pt;font-style:italic;color:#555;text-align:center;margin-bottom:8pt;}' 13924 +'.loi-foot{text-align:center;font-size:8pt;color:#888;border-top:1px solid #ccc;padding-top:5pt;margin-top:14pt;}' 13925 +'.loi-pbline{color:#333;}' 13926 +'.loi-h{font-size:13pt;font-weight:bold;border-bottom:1px solid #999;margin:12pt 0 4pt;}' 13927 +'.loi-intro{font-size:10.5pt;}' 13928 +'table{width:100%;border-collapse:collapse;}' 13929 +'table.loi-meta td.k, table.loi-terms td.k{font-weight:bold;vertical-align:top;padding:3pt 10px 3pt 0;}' 13930 +'table.loi-terms td.v, table.loi-meta td.v{vertical-align:top;padding:3pt 0;}' 13931 +'.sh{font-weight:bold;} .sl{margin-top:8pt;} .sn{font-weight:bold;} p{margin:6pt 0;}' 13932 +'</style>'; 13933 var html='<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40"><head><meta charset="utf-8">'+css+'</head><body>'+body+'</body></html>'; 13934 var blob=new Blob([''+html],{type:'application/msword'}); 13935 var a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=name+'.doc'; a.click(); 13936 setTimeout(function(){ try{ URL.revokeObjectURL(a.href); }catch(e){} },2000); done(); 13937 })(); 13938} 13939function loiExport(loi, kind, btn){ 13940 var fname=loiAskName(loi); if(fname===null) return; 13941 if(btn){ btn.textContent='Buildingâ¦'; btn.disabled=true; } 13942 var done=function(){ if(btn){ btn.textContent='Export PDF'; btn.disabled=false; } }; 13943 dsLoadScript('https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js').then(function(){ return _docCanvas('loiSheet'); }).then(function(canvas){ 13944 var img=canvas.toDataURL('image/png'); var JsPDF=(window.jspdf&&window.jspdf.jsPDF)||window.jsPDF; 13945 var pdf=new JsPDF('p','pt','letter'); var pw=612, ph=792, m=24; 13946 var iw=pw-m*2, ih=canvas.height*(iw/canvas.width); var pageH=ph-m*2; 13947 if(ih<=pageH){ pdf.addImage(img,'PNG',m,m,iw,ih); } 13948 else { var pages=Math.ceil(ih/pageH); for(var p=0;p<pages;p++){ if(p>0) pdf.addPage(); pdf.addImage(img,'PNG',m,m-p*pageH,iw,ih); } } 13949 pdf.save(fname+'.pdf'); done(); 13950 }).catch(function(e){ done(); alert('PDF export failed: '+(e&&e.message||e)); }); 13951} 13952// ============================================================================ 13953// LISTINGS â canonical marketable-property record. Enter once; generate the website post, a social 13954// caption, and (later) drip content from it. MLS stays the system of record; this is built from the 13955// same inputs, not synced. v1 stores everything in the `data` JSON so the shape can evolve freely. 13956// ============================================================================ 13957var LIST_STATUS=[['draft','Draft'],['coming_soon','Coming Soon'],['active','Active'],['under_contract','Under Contract'],['pending','Pending'],['sold','Sold'],['withdrawn','Withdrawn'],['expired','Expired']]; 13958var LIST_TYPES=[['residential','Residential'],['land','Land'],['commercial','Commercial']]; 13959var _listView='all', _listQuery=''; 13960function listStatusLabel(v){ for(var i=0;i<LIST_STATUS.length;i++){ if(LIST_STATUS[i][0]===v) return LIST_STATUS[i][1]; } return v||''; } 13961function listTypeLabel(v){ for(var i=0;i<LIST_TYPES.length;i++){ if(LIST_TYPES[i][0]===v) return LIST_TYPES[i][1]; } return v||''; } 13962function listStatusColor(v){ return ({draft:'#888',coming_soon:'#7a5cff',active:'#1f9d55',under_contract:'#d08b1f',pending:'#c0692b',sold:'#c0392b',withdrawn:'#888',expired:'#888'})[v]||'#888'; } 13963function listSlugify(s){ return String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,80); } 13964function listArchived(l){ return !!(l.data&&l.data.archived); } 13965function listMoney(v){ v=dsNum(v);
13965 return v?('$'+v.toLocaleString('en-US',{maximumFractionDigits:0})):''; } 13966function listDefault(){ return { id:Date.now()+Math.floor(Math.random()*100000), contact_id:null, name:'', data:{ 13967 status:'draft', property_type:'residential', transaction_id:null, mls_number:'', slug:'', slug_locked:false, 13968 address_full:'', street:'', city:'', state:'UT', zip:'', county:'', 13969 price:'', price_qualifier:'', hoa_monthly:'', taxes_annual:'', lot_size:'', year_built:'', parking:'', 13970 details:{}, headline_en:'', headline_es:'', pitch_en:'', pitch_es:'', 13971 features:'', highlights:'', hero_image_url:'', gallery_url:'', video_url:'', tour_url:'', open_house:'', 13972 mls_url:'', card_note:'', source_notes:'', archived:false } }; } 13973// type-specific detail fields: [key,label,type] 13974function listDetailFields(type){ 13975 if(type==='land') return [['acreage','Acreage','text'],['zoning','Zoning','text'],['utilities','Utilities','text'],['road_access','Road access','text'],['water_rights','Water rights','text'],['buildable','Buildable? (yes/no)','text']]; 13976 if(type==='commercial') return [['building_sqft','Building SF','number'],['rentable_sqft','Rentable SF','number'],['use_type','Use type','text'],['for_sale_or_lease','For sale or lease','text'],['lease_type','Lease type (NNN, gross)','text'],['cam_per_sf','CAM $/SF/yr','text'],['cap_rate','Cap rate %','text'],['zoning','Zoning','text'],['parking_ratio','Parking ratio','text'],['occupancy','Occupancy','text']]; 13977 return [['beds','Beds','number'],['baths_full','Full baths','number'],['baths_half','Half baths','number'],['sqft','Sq ft','number'],['garage_spaces','Garage spaces','number'],['stories','Stories','number'],['basement','Basement','text'],['style','Style','text']]; 13978} 13979// short "spec line" per type for previews and outputs 13980function listSpecLine(d){ 13981 var x=d.details||{}; 13982 if(d.property_type==='land'){ return [x.acreage?(x.acreage+' ac'):'', d.lot_size, x.zoning?('Zoned '+x.zoning):''].filter(Boolean).join(' · '); } 13983 if(d.property_type==='commercial'){ return [x.rentable_sqft?(Number(x.rentable_sqft).toLocaleString()+' SF'):'', x.use_type, x.lease_type].filter(Boolean).join(' · '); } 13984 var b=[]; if(x.beds) b.push(x.beds+' bd'); var ba=(parseFloat(x.baths_full)||0)+(parseFloat(x.baths_half)||0)*0.5; if(ba) b.push(ba+' ba'); if(x.sqft) b.push(Number(x.sqft).toLocaleString()+' sf'); return b.join(' · '); 13985} 13986function _listArr(s){ return String(s||'').split(/\r?\n/).map(function(x){return x.trim();}).filter(Boolean); } 13987function _listClip(text, btn, okLabel){ 13988 var restore=btn?btn.textContent:''; function ok(){ if(btn){ btn.textContent=okLabel||'Copied â'; setTimeout(function(){btn.textContent=restore;},1500); } } 13989 try{ if(navigator.clipboard&&navigator.clipboard.writeText){ navigator.clipboard.writeText(text).then(ok, function(){ _listClipFallback(text); ok(); }); return; } }catch(e){} 13990 _listClipFallback(text); ok(); 13991} 13992function _listClipFallback(text){ try{ var ta=document.createElement('textarea'); ta.value=text; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.focus(); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); }catch(e){ alert('Copy failed â select and copy manually.'); } } 13993// ---- transforms (one entry -> many outputs) ---- 13994// The beds/baths/sqft line, in the format the site's cards use ("5 bdrm / 2 bath â 3,296 sq ft"). 13995function listCardSpec(d){ 13996 var x=d.details||{}; 13997 if(d.property_type==='land'){ return [x.acreage?(x.acreage+' acres'):'', x.zoning?('zoned '+x.zoning):''].filter(Boolean).join(' â '); } 13998 if(d.property_type==='commercial'){ return [x.rentable_sqft?(Number(x.rentable_sqft).toLocaleString()+' sq ft'):'', x.use_type].filter(Boolean).join(' â '); } 13999 var parts=[]; if(x.beds) parts.push(x.beds+' bdrm'); var ba=(parseFloat(x.baths_full)||0)+(parseFloat(x.baths_half)||0)*0.5; if(ba) parts.push(ba+' bath'); 14000 var bb=parts.join(' / '); var sf=x.sqft?(Number(x.sqft).toLocaleString()+' sq ft'):''; 14001 return [bb, sf].filter(Boolean).join(' â '); 14002} 14003// A ready-to-paste Gutenberg card block matching the site's Listings grid: photo + "City â $Price" 14004// heading (linked to the MLS listing) + beds/baths/sqft line. Paste into a column on the Listings page. 14005function listWebsiteCard(d){ 14006 var mls=d.mls_url||''; var city=d.city||String(d.address_full||'').split(',')[0].trim(); 14007 var price=listMoney(d.price);
14008 var head=[city, price].filter(Boolean).join(' â '); if(!head) head=d.headline_en||d.address_full||'Listing'; 14009 var note=d.card_note?(' '+_esc(d.card_note)):''; var spec=listCardSpec(d); var img=d.hero_image_url||''; var L=[]; 14010 if(img){ 14011 L.push('<!-- wp:image {"sizeSlug":"large"'+(mls?',"linkDestination":"custom"':'')+'} -->'); 14012 L.push('<figure class="wp-block-image size-large">'+(mls?('<a href="'+_esc(mls)+'">'):'')+'<img src="'+_esc(img)+'" alt="'+_esc(city)+'"/>'+(mls?'</a>':'')+'</figure>'); 14013 L.push('<!-- /wp:image -->'); L.push(''); 14014 } else { L.push('<!-- Add the property photo here in WordPress (Image block). -->'); L.push(''); } 14015 L.push('<!-- wp:heading {"textAlign":"center","level":4} -->'); 14016 L.push('<h4 class="wp-block-heading has-text-align-center">'+(mls?('<a href="'+_esc(mls)+'">'+_esc(head)+'</a>'):_esc(head))+note+'</h4>'); 14017 L.push('<!-- /wp:heading -->'); L.push(''); 14018 if(spec){ L.push('<!-- wp:paragraph {"align":"center"} -->'); L.push('<p class="has-text-align-center">'+_esc(spec)+'</p>'); L.push('<!-- /wp:paragraph -->'); } 14019 return L.join('\n'); 14020} 14021function listSocialCaption(d){ 14022 var hl=_listArr(d.highlights); if(!hl.length) hl=_listArr(d.features).slice(0,3); 14023 var price=listMoney(d.price); if(price&&d.price_qualifier) price+=' '+d.price_qualifier; 14024 var L=[]; L.push((d.headline_en||d.address_full||'New Listing').toUpperCase()); 14025 var spec=listSpecLine(d); var line2=[price,spec].filter(Boolean).join(' ⢠'); if(line2) L.push(line2); 14026 if(d.address_full) L.push('ð '+d.address_full); 14027 hl.forEach(function(h){ L.push('⢠'+h); }); 14028 L.push(''); L.push('DM us for a private showing. Palacios Baker Real Estate · Utahâs Wise Choice'); 14029 if(d.pitch_es||d.headline_es){ L.push(''); L.push('â '+(d.headline_es||'Nueva propiedad en venta')); } 14030 L.push(''); L.push('#UtahRealEstate #'+((d.city||'Utah').replace(/[^A-Za-z0-9]/g,''))+'RealEstate #PalaciosBaker #WiseChoice'); 14031 return L.join('\n'); 14032} 14033function _firstSentences(t,n){ if(!t) return ''; var p=String(t).trim().split('. '); return p.slice(0,n).join('. ').replace(/\.*$/,'')+'.'; } 14034// New-listing announcement generated from the record â email or text, English + Spanish. Copy it to 14035// blast your sphere, or paste into a drip/broadcast. (Third of the "one entry, several outputs" set.) 14036function listAnnouncement(d, kind){ 14037 var city=d.city||String(d.address_full||'').split(',')[0].trim(); 14038 var price=listMoney(d.price); if(price&&d.price_qualifier) price+=' '+d.price_qualifier; 14039 var spec=listCardSpec(d), feats=_listArr(d.features).slice(0,3), link=d.mls_url||''; 14040 var headEn=d.headline_en||('New listing in '+city), headEs=d.headline_es||('Nueva propiedad en '+city); 14041 if(kind==='sms'){ 14042 var en='ð¡ Just listed'+(city?(' in '+city):'')+': '+[price,spec].filter(Boolean).join(', ')+'. '+(link?('Details: '+link):'Reply for info.')+' â Palacios Baker RE'; 14043 var es='ð¡ Recién listada'+(city?(' en '+city):'')+': '+[price,spec].filter(Boolean).join(', ')+'. '+(link?('Info: '+link):'Responda para más.')+' â Palacios Baker RE'; 14044 return 'ENGLISH (text):\n'+en+'\n\nESPAÃOL (texto):\n'+es; 14045 } 14046 var line=[price,spec].filter(Boolean).join(' · '); 14047 var en=[]; en.push('Subject: Just Listed: '+headEn+(price?(' â '+price):'')); en.push(''); 14048 en.push('Just listed'+(city?(' in '+city):'')+' â '+headEn+'.'); if(d.address_full) en.push(d.address_full); if(line) en.push(line); 14049 if(feats.length){ en.push('');
14049 feats.forEach(function(f){ en.push('⢠'+f); }); } 14050 if(d.pitch_en){ en.push(''); en.push(_firstSentences(d.pitch_en,2)); } 14051 en.push(''); en.push(link?('See photos & details: '+link):'Reply to this email and Iâll send full details and set up a showing.'); 14052 en.push('â Palacios Baker Real Estate · Utahâs Wise Choice'); 14053 var es=[]; es.push('Asunto: Recién listada: '+headEs+(price?(' â '+price):'')); es.push(''); 14054 es.push('Recién listada'+(city?(' en '+city):'')+' â '+headEs+'.'); if(d.address_full) es.push(d.address_full); if(line) es.push(line); 14055 if(feats.length){ es.push(''); feats.forEach(function(f){ es.push('⢠'+f); }); } 14056 if(d.pitch_es){ es.push(''); es.push(_firstSentences(d.pitch_es,2)); } 14057 es.push(''); es.push(link?('Vea fotos y detalles: '+link):'Responda a este correo y le enviaré todos los detalles y coordinaré una visita.'); 14058 es.push('â Palacios Baker Real Estate · Utahâs Wise Choice'); 14059 return 'ENGLISH (email):\n'+en.join('\n')+'\n\n----------\n\nESPAÃOL (correo):\n'+es.join('\n'); 14060} 14061// ---- auto-fill from a pasted listing page (utahrealestate.com etc.) via the AI extractor ---- 14062function listAutofillPrompt(text){ 14063 return 'You are extracting real-estate listing facts from the pasted listing/MLS web-page text below. ' 14064 +'Return ONLY a JSON object (no prose, no markdown, no code fences) with exactly these keys; use null when a value is not present:\n' 14065 +'{"address_full":string,"city":string,"state":string,"zip":string,"county":string,"price":number,' 14066 +'"status":one of ["draft","coming_soon","active","under_contract","pending","sold","withdrawn","expired"],' 14067 +'"property_type":one of ["residential","land","commercial"],"mls_number":string,"year_built":number,' 14068 +'"lot_size":string,"parking":string,"hoa_monthly":number,"taxes_annual":number,"beds":number,' 14069 +'"baths_full":number,"baths_half":number,"sqft":number,"style":string,"headline_en":string,' 14070 +'"pitch_en":string,"features":[string]}\n' 14071 +'Rules: numbers must be plain (no commas, $, or units). Map "Accepting Backup Offers" to "active". ' 14072 +'If it is vacant land with no beds/baths, set property_type to "land". headline_en = a short marketable ' 14073 +'title (city plus one highlight). pitch_en = the cleaned property description paragraph. ' 14074 +'features = 4-8 short standout-feature phrases. Ignore navigation, schools lists, ads, and agent contact blocks.\n\n' 14075 +'PASTED TEXT:\n'+String(text||'').slice(0,14000); 14076} 14077function listAutofill(loi, textarea, btn){ 14078 var text=(textarea.value||'').trim(); if(text.length<40){ alert('Paste the listing page text first (open it on the MLS site, select all, copy).'); return; } 14079 var orig=btn.textContent; btn.textContent='Readingâ¦'; btn.disabled=true; 14080 (async function(){ 14081 try{ 14082 var resp=await fetch('/api/claude',{method:'POST',headers:await apiHeaders(),body:JSON.stringify({ max_tokens:4096, response_format:'json', messages:[{ role:'user', content:listAutofillPrompt(text) }] })}); 14083 var data=await resp.json(); if(data&&data.error) throw new Error(data.error.message||JSON.stringify(data.error)); 14084 var raw=(data.content&&data.content[0]&&data.content[0].text)||''; var r=_parseJsonLoose(raw); 14085 if(!r) throw new Error('Could not read the fields from that text.'); 14086 var d=loi.data; d.details=d.details||{}; 14087 function setT(k,v){ if(v!=null&&v!=='') d[k]=v; } 14088 setT('address_full',r.address_full); setT('city',r.city); setT('state',r.state); setT('zip',r.zip); setT('county',r.county); 14089 if(r.price!=null) d.price=r.price; 14090 setT('status',r.status); setT('property_type',r.property_type); setT('mls_number',r.mls_number); 14091 if(r.year_built!=null) d.year_built=r.year_built; setT('lot_size',r.lot_size); setT('parking',r.parking); 14092 if(r.hoa_monthly!=null) d.hoa_monthly=r.hoa_monthly; if(r.taxes_annual!=null) d.taxes_annual=r.taxes_annual; 14093 setT('headline_en',r.headline_en); setT('pitch_en',r.pitch_en); 14094 if(Array.isArray(r.features)&&r.features.length) d.features=r.features.filter(Boolean).join('\n'); 14095 var x=d.details; 14096 if(r.beds!=null)x.beds=r.beds; if(r.baths_full!=null)x.baths_full=r.baths_full; if(r.baths_half!=null)x.baths_half=r.baths_half; if(r.sqft!=null)x.sqft=r.sqft; if(r.style)x.style=r.style; 14097 openListing(loi,false); 14098 }catch(e){ alert('Auto-fill failed: '+(e&&e.message||e)); btn.textContent=orig; btn.disabled=false; } 14099 })(); 14100} 14101// ---- styles ---- 14102function _listStyle(){ 14103 if(ge('listCss')) return; var s=document.createElement('style'); s.id='listCss'; 14104 s.textContent= 14105 '#page-listings .lbtn{background:var(--accent);color:#fff;border:none;border-radius:7px;padding:7px 14px;font-family:inherit;font-size:14px;cursor:pointer;}' 14106 +'#page-listings .lbtn.g{background:transparent;border:1px solid var(--border);color:var(--text);}' 14107 +'#page-listings .l-form{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:14px;margin-bottom:16px;}' 14108 +'#page-listings .l-form label{font-size:12px;color:var(--text3);display:block;margin-bottom:3px;}' 14109 +'#page-listings .l-form input,#page-listings .l-form textarea,#page-listings .l-form select{width:100%;box-sizing:border-box;background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-family:inherit;font-size:13px;}' 14110 +'#page-listings .l-sec{grid-column:1/-1;font-size:13px;font-weight:700;color:var(--text2);margin:6px 0 -2px;border-top:1px solid var(--border);padding-top:10px;}' 14111 +'#page-listings .l-sec:first-child{border-top:none;padding-top:0;}' 14112 +'#page-listings .l-wide{grid-column:1/-1;}' 14113 +'#page-listings .l-prev{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:16px;}' 14114 +'#page-listings .l-badge{display:inline-block;font-size:10px;font-weight:700;color:#fff;border-radius:4px;padding:2px 7px;vertical-align:middle;}'; 14115 document.head.appendChild(s); 14116} 14117function renderListings(){ 14118 _listStyle(); var root=ge('listRoot'); if(!root) return; root.innerHTML='';
14119 var bar=document.createElement('div'); bar.style.cssText='display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;'; 14120 bar.appendChild(mkDivSafe('font-size:22px;font-weight:600;','Listings')); 14121 var nb=document.createElement('button'); nb.className='lbtn'; nb.textContent='+ New Listing'; nb.addEventListener('click',function(){ openListing(listDefault(), true); }); bar.appendChild(nb); root.appendChild(bar); 14122 root.appendChild(mkDivSafe('color:var(--text3);font-size:12px;margin:-4px 0 12px;','Enter a property once â then copy the website post and a social caption from it. The MLS stays your system of record.')); 14123 if(!LISTINGS.length){ root.appendChild(mkDivSafe('color:var(--text3);padding:20px 0;','No listings yet. Create one and fill the fields.')); return; } 14124 var ctr=document.createElement('div'); ctr.style.cssText='display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px;'; 14125 var search=document.createElement('input'); search.type='text'; search.placeholder='Search listings (address, city, MLS)â¦'; search.value=_listQuery; 14126 search.style.cssText='flex:1;
14126min-width:200px;background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:7px 10px;font-family:inherit;font-size:13px;'; 14127 var sel=document.createElement('select'); sel.style.cssText='background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:7px 8px;font-family:inherit;font-size:13px;width:auto;'; 14128 var opts=[['all','All active']].concat(LIST_STATUS).concat([['archived','Archived']]); 14129 opts.forEach(function(o){ var op=document.createElement('option'); op.value=o[0]; op.textContent=o[1]; if(o[0]===_listView) op.selected=true; sel.appendChild(op); }); 14130 ctr.appendChild(search); ctr.appendChild(sel); root.appendChild(ctr); 14131 var list=document.createElement('div'); list.style.cssText='display:flex;flex-direction:column;gap:8px;'; root.appendChild(list); 14132 function draw(){ 14133 list.innerHTML=''; var q=_listQuery.trim().toLowerCase(); 14134 var items=LISTINGS.filter(function(l){ 14135 var d=l.data||{}, arch=listArchived(l); 14136 if(q){ var hay=((l.name||'')+' '+(d.address_full||'')+' '+(d.city||'')+' '+(d.mls_number||'')).toLowerCase(); if(hay.indexOf(q)<0) return false; return true; } 14137 if(_listView==='archived') return arch; 14138 if(arch) return false; 14139 if(_listView==='all') return true; 14140 return d.status===_listView; 14141 }).sort(function(a,b){ return String(b.updated_at||'').localeCompare(String(a.updated_at||'')); }); 14142 if(!items.length){ list.appendChild(mkDivSafe('color:var(--text3);padding:16px 0;', q?'No listings match your search.':'Nothing here.')); return; } 14143 items.forEach(function(l){ 14144 var d=l.data||{}, arch=listArchived(l); 14145 var row=document.createElement('div'); row.style.cssText='display:flex;justify-content:space-between;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:10px 14px;cursor:pointer;'+(arch?'opacity:.7;':''); 14146 var badge='<span class="l-badge" style="background:'+listStatusColor(d.status)+';">'+_esc(listStatusLabel(d.status||'draft'))+'</span>'; 14147 var sub=[listTypeLabel(d.property_type), listSpecLine(d), (d.updated_at?('updated '+fd(l.updated_at)):'')].filter(Boolean).join(' · '); 14148 row.innerHTML='<div style="min-width:0;"><div style="font-weight:600;overflow:hidden;text-overflow:ellipsis;">'+_esc(l.name||d.address_full||'Untitled listing')+' '+badge+(arch?' <span class="l-badge" style="background:#888;">ARCHIVED</span>':'')+'</div><div style="font-size:12px;color:var(--text3);">'+_esc(sub)+(d.price?(' · '+listMoney(d.price)):'')+'</div></div>'; 14149 var btns=document.createElement('div'); btns.style.cssText='display:flex;gap:6px;flex-shrink:0;'; 14150 var ab=document.createElement('button'); ab.className='lbtn g'; ab.style.cssText='font-size:12px;padding:3px 9px;'; ab.textContent=arch?'Unarchive':'Archive'; 14151 ab.addEventListener('click',function(e){ e.stopPropagation(); if(!l.data)l.data={}; l.data.archived=!arch; saveListing(l); draw(); }); 14152 var del=document.createElement('button'); del.className='lbtn g'; del.style.cssText='font-size:12px;padding:3px 9px;'; del.textContent='Delete'; 14153 (function(id){ del.addEventListener('click',function(e){ e.stopPropagation(); if(confirm('Delete this listing?')){ delListing(id); draw(); } }); })(l.id); 14154 btns.appendChild(ab); btns.appendChild(del); 14155 row.addEventListener('click',function(){ openListing(l,false); }); row.appendChild(btns); list.appendChild(row); 14156 }); 14157 } 14158 search.addEventListener('input',function(){ _listQuery=search.value; draw(); }); 14159 sel.addEventListener('change',function(){ _listView=sel.value; draw(); }); 14160 draw(); 14161} 14162function openListing(loi, isNew){ 14163 _listStyle(); var root=ge('listRoot'); if(!root) return; root.innerHTML=''; var d=loi.data; 14164 var tb=document.createElement('div'); tb.style.cssText='display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px;'; 14165 var back=document.createElement('button'); back.className='lbtn g'; back.textContent='â¹ Back'; back.addEventListener('click',renderListings); tb.appendChild(back); 14166 var cWrap=document.createElement('div'); cWrap.style.cssText='min-width:240px;'; tb.appendChild(cWrap); 14167 var picker=buildContactPicker(cWrap,'list_client','Link seller contact (optional) â name or addressâ¦',function(){ var id=picker.hidden.value; if(!id) return; var c=gc(parseInt(id)); if(!c) return; loi.contact_id=parseInt(id); if(!d.address_full){ d.address_full=c.property||c.address||''; } if(!loi.name) loi.name=d.address_full||fn(c); openListing(loi,isNew); }); 14168 var saveB=document.createElement('button');
14168 saveB.className='lbtn'; saveB.textContent='Save listing'; saveB.title='Store this record in your CRM (Listings list). Nothing is published.'; saveB.addEventListener('click',function(){ if(!loi.name) loi.name=d.address_full||'Listing'; if(!LISTINGS.some(function(x){return String(x.id)===String(loi.id);})) LISTINGS.push(loi); saveListing(loi); saveB.textContent='Saved â'; setTimeout(function(){saveB.textContent='Save listing';},1500); }); tb.appendChild(saveB); 14169 var webB=document.createElement('button'); webB.className='lbtn'; webB.textContent='Copy website card'; webB.title='Copy a ready-to-paste card block for your Listings page grid in WordPress.'; webB.addEventListener('click',function(){ _listClip(listWebsiteCard(d), webB, 'Copied â paste into a column on your Listings page â'); }); tb.appendChild(webB); 14170 var socB=document.createElement('button'); socB.className='lbtn g'; socB.textContent='Copy for Instagram / Facebook'; socB.title='Copy a caption to your clipboard. Paste it into your social post and attach your photos.'; socB.addEventListener('click',function(){ _listClip(listSocialCaption(d), socB, 'Copied â now paste in your post â'); }); tb.appendChild(socB); 14171 var emailB=document.createElement('button'); emailB.className='lbtn g'; emailB.textContent='Copy email blast'; emailB.title='Copy a Just-Listed email (English + Spanish) to send to your sphere.'; emailB.addEventListener('click',function(){ _listClip(listAnnouncement(d,'email'), emailB, 'Copied â'); }); tb.appendChild(emailB); 14172 var smsB=document.createElement('button'); smsB.className='lbtn g'; smsB.textContent='Copy text blast'; smsB.title='Copy a short Just-Listed text message (English + Spanish).'; smsB.addEventListener('click',function(){ _listClip(listAnnouncement(d,'sms'), smsB, 'Copied â'); }); tb.appendChild(smsB); 14173 root.appendChild(tb); 14174 root.appendChild(mkDivSafe('color:var(--text3);font-size:12px;margin:-4px 0 12px;line-height:1.5;','Fill the fields once and <b>Save listing</b> to store it here â then generate every output from it: <b>Copy website card</b> (paste into your Listings page grid in WordPress), <b>Copy for Instagram / Facebook</b> (a social caption), and <b>Copy email blast</b> / <b>Copy text blast</b> (a Just-Listed announcement in English + Spanish to send your sphere).')); 14175 14176 var prev=document.createElement('div'); prev.className='l-prev'; 14177 var form=document.createElement('div'); form.className='l-form'; 14178 function refresh(){ 14179 if(!d.slug_locked) d.slug=listSlugify(d.address_full); 14180 var spec=listSpecLine(d); var price=listMoney(d.price); if(price&&d.price_qualifier) price+=' '+d.price_qualifier; 14181 var feats=_listArr(d.features); 14182 prev.innerHTML='<div style="display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;align-items:baseline;">' 14183 +'<div style="font-size:18px;font-weight:700;">'+_esc(d.headline_en||d.address_full||'New listing')+'</div>' 14184 +'<span class="l-badge" style="background:'+listStatusColor(d.status)+';">'+_esc(listStatusLabel(d.status))+'</span></div>' 14185 +(d.address_full?('<div style="color:var(--text3);font-size:13px;">'+_esc(d.address_full)+'</div>'):'') 14186 +'<div style="margin:6px 0;font-weight:600;">'+_esc([price,spec].filter(Boolean).join(' · '))+'</div>' 14187 +(d.hero_image_url?('<img src="'+_esc(d.hero_image_url)+'" alt="" style="max-width:100%;border-radius:8px;margin:4px 0;">'):'') 14188 +(d.pitch_en?('<div style="font-size:13px;margin:6px 0;">'+_esc(d.pitch_en).replace(/\n/g,'<br>')+'</div>'):'') 14189 +(feats.length?('<ul style="margin:6px 0 0;padding-left:18px;font-size:13px;">'+feats.map(function(f){return '<li>'+_esc(f)+'</li>';}).join('')+'</ul>'):'') 14190 +'<div style="color:var(--text3);font-size:11px;margin-top:8px;">Slug: /'+_esc(d.slug||'')+(d.mls_number?(' · MLS '+_esc(d.mls_number)):'')+'</div>'; 14191 } 14192 function sec(t){ var w=document.createElement('div'); w.className='l-sec'; w.textContent=t; form.appendChild(w); } 14193 function fld(key,label,type,wide){ var w=document.createElement('div'); if(wide)w.className='l-wide'; var l=document.createElement('label'); l.textContent=label; w.appendChild(l); 14194 var el = type==='textarea'?document.createElement('textarea'):document.createElement('input'); if(type&&type!=='textarea')el.type=type; if(type==='textarea')el.rows=3; 14195 el.value=(d[key]!=null?d[key]:''); el.addEventListener('input',function(){ d[key]=el.value; if(key==='slug') d.slug_locked=true; refresh(); }); w.appendChild(el); form.appendChild(w); return el; } 14196 function dfld(key,label,type){ var w=document.createElement('div'); var l=document.createElement('label'); l.textContent=label; w.appendChild(l); 14197 var el=document.createElement('input'); if(type)el.type=type; if(!d.details)d.details={}; el.value=(d.details[key]!=null?d.details[key]:''); el.addEventListener('input',function(){ if(!d.details)d.details={}; d.details[key]=el.value; refresh(); }); w.appendChild(el); form.appendChild(w); } 14198 function pick(key,label,opts){ var w=document.createElement('div'); var l=document.createElement('label'); l.textContent=label; w.appendChild(l); 14199 var s=document.createElement('select');
14199 opts.forEach(function(o){ var op=document.createElement('option'); op.value=o[0]; op.textContent=o[1]; if(d[key]===o[0])op.selected=true; s.appendChild(op); }); s.value=d[key]; s.addEventListener('change',function(){ d[key]=s.value; if(key==='property_type') openListing(loi,isNew); else refresh(); }); w.appendChild(s); form.appendChild(w); } 14200 function tgl(key,label){ var w=document.createElement('div'); w.className='l-wide'; var lab=document.createElement('label'); lab.style.cssText='display:flex;align-items:center;gap:8px;cursor:pointer;color:var(--text);'; var cb=document.createElement('input'); cb.type='checkbox'; cb.checked=!!d[key]; cb.style.width='auto'; cb.addEventListener('change',function(){ d[key]=cb.checked; }); lab.appendChild(cb); lab.appendChild(document.createTextNode(label)); w.appendChild(lab); form.appendChild(w); } 14201 14202 var af=document.createElement('div'); af.className='l-wide'; af.style.cssText='background:var(--surface2);border:1px dashed var(--border);border-radius:8px;padding:10px;'; 14203 af.appendChild(mkDivSafe('font-size:12px;color:var(--text3);margin-bottom:6px;line-height:1.5;','<b>Auto-fill from a listing page.</b> Open the listing on utahrealestate.com, select all (Ctrl+A) and copy (Ctrl+C), paste below, and click Extract & fill. Review the fields it pulls in.')); 14204 var afta=document.createElement('textarea'); afta.rows=3; afta.placeholder='Paste the listing page text hereâ¦'; afta.style.cssText='width:100%;box-sizing:border-box;background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-family:inherit;font-size:13px;'; 14205 var afb=document.createElement('button'); afb.className='lbtn'; afb.textContent='Extract & fill'; afb.style.cssText='margin-top:6px;'; afb.addEventListener('click',function(){ listAutofill(loi,afta,afb); }); 14206 af.appendChild(afta); af.appendChild(afb); form.appendChild(af); 14207 sec('Status & identity'); 14208 pick('status','Status',LIST_STATUS); pick('property_type','Property type',LIST_TYPES); 14209 fld('mls_number','MLS #'); fld('slug','URL slug (auto from address)'); 14210 // Linked transaction (the seller deal this listing belongs to) 14211 var txWrap=document.createElement('div'); var txl=document.createElement('label'); txl.textContent='Linked transaction (optional)'; txWrap.appendChild(txl); 14212 var txs=document.createElement('select'); var none=document.createElement('option'); none.value=''; none.textContent='(none)'; txs.appendChild(none); 14213 (typeof TX!=='undefined'?TX:[]).slice().sort(function(a,b){ return String(txAddr(a)).localeCompare(String(txAddr(b))); }).forEach(function(t){ var op=document.createElement('option'); op.value=String(t.id); var cN=gc(t.contactId); op.textContent=(txAddr(t)||'(no address)')+' â '+(t.type||'')+(cN?(' · '+fn(cN)):''); txs.appendChild(op); }); 14214 txs.value = (d.transaction_id!=null?String(d.transaction_id):''); 14215 txs.addEventListener('change',function(){ 14216 d.transaction_id = txs.value?(parseInt(txs.value)||txs.value):null; 14217 if(d.transaction_id){ var t=(TX||[]).filter(function(x){return String(x.id)===String(d.transaction_id);})[0]; if(t){ if(!d.address_full) d.address_full=txAddr(t); if(loi.contact_id==null && t.contactId!=null) loi.contact_id=t.contactId; } } 14218 openListing(loi,false); 14219 }); 14220 txWrap.appendChild(txs); 14221 if(d.transaction_id){ var lk=document.createElement('div'); lk.style.cssText='font-size:11px;margin-top:3px;'; var a=document.createElement('a'); a.href='#'; a.textContent='Open transaction âº'; a.style.color='var(--accent)'; a.addEventListener('click',function(e){ e.preventDefault(); if(typeof sp==='function') sp('tc'); if(typeof openTCDetail==='function') openTCDetail(d.transaction_id); }); lk.appendChild(a); txWrap.appendChild(lk); } 14222 form.appendChild(txWrap); 14223 sec('Address & price'); 14224 fld('address_full','Full address (display)','text',true); 14225 fld('city','City'); fld('state','State'); fld('zip','ZIP'); fld('county','County'); 14226 fld('price','Price ($)','number'); fld('price_qualifier','Price qualifier (OBO, /SF NNNâ¦)'); 14227 fld('hoa_monthly','HOA ($/mo)','number'); fld('taxes_annual','Taxes ($/yr)','number'); 14228 fld('lot_size','Lot size'); fld('year_built','Year built','number'); fld('parking','Parking'); 14229 sec(listTypeLabel(d.property_type)+' details'); 14230 listDetailFields(d.property_type).forEach(function(f){ dfld(f[0],f[1],f[2]); }); 14231 sec('Marketing â English'); 14232 fld('headline_en','Headline (EN)','text',true); 14233 fld('pitch_en','Description (EN)','textarea',true); 14234 sec('Marketing â Español'); 14235 fld('headline_es','Headline (ES)','text',true); 14236 fld('pitch_es','Description (ES)','textarea',true); 14237 sec('Features & media'); 14238 fld('features','Standout features (one per line)','textarea',true); 14239 fld('highlights','Social highlights (one per line, ~3)','textarea',true); 14240 fld('hero_image_url','Hero image URL','text',true); 14241 fld('gallery_url','Photo gallery URL (Dropbox/Google/MLS)','text',true); 14242 fld('video_url','Video URL'); fld('tour_url','3D tour URL'); 14243 fld('open_house','Open house (free text)','text',true); 14244 sec('Website card'); 14245 fld('mls_url','MLS listing URL â the utahrealestate.com link the card opens','text',true); 14246 fld('card_note','Card note (optional, e.g. "-seller financing available")','text',true); 14247 sec('Notes'); 14248 fld('source_notes','Source notes (raw facts you fed the MLS)','textarea',true); 14249 14250 root.appendChild(form); 14251 root.appendChild(prev); refresh(); 14252} 14253// ============================================================================ 14254// HARD MONEY LENDING 14255// ============================================================================ 14256var _hmFilter='active'; // all | active | paid off 14257function loanActive(l){ return (l.status||'active') !== 'paid off'; } 14258function loanSchedule(l){ 14259 var out=[]; var start=l.first_payment_date||l.start_date; var n=parseInt(l.term_months)||0; 14260 if(!start || n<=0) return out; 14261 var p=String(start).slice(0,10).split('-'); if(p.length<3) return out; 14262 var y=+p[0], m=+p[1]-1, d=+p[2]; if(isNaN(y)||isNaN(m)) return out; 14263 for(var i=0;i<n;i++){ var dt=new Date(y, m+i, d||1); out.push(dt.getFullYear()+'-'+String(dt.getMonth()+1).padStart(2,'0')+'-'+String(dt.getDate()).padStart(2,'0')); } 14264 return out; 14265} 14266function loanPaidByMonth(id){ var map={};
14266 paymentsForLoan(id).forEach(function(p){ if(p.date) map[String(p.date).slice(0,7)]=p; }); return map; } 14267function loanReceived(id){ return paymentsForLoan(id).reduce(function(s,p){ return s+invNum(p.amount); }, 0); } 14268 14269function ivbtn(label,cls,fn){ var b=document.createElement('button'); b.className='ivbtn'+(cls?(' '+cls):''); b.textContent=label; b.addEventListener('click',fn); return b; } 14270 14271function renderHardMoney(){ 14272 _ivStyle(); 14273 var root=ge('hmRoot'); if(!root) return; root.innerHTML=''; 14274 14275 var bar=document.createElement('div'); bar.style.cssText='display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:14px;'; 14276 var lft=document.createElement('div'); lft.style.cssText='display:flex;align-items:center;gap:8px;'; 14277 var fsel=document.createElement('select'); fsel.style.width='auto'; 14278 [['active','Active'],['all','All'],['paid off','Paid off']].forEach(function(o){ var op=document.createElement('option'); op.value=o[0]; op.textContent=o[1]; if(o[0]===_hmFilter) op.selected=true; fsel.appendChild(op); }); 14279 fsel.addEventListener('change',function(){ _hmFilter=fsel.value; renderHardMoney(); }); 14280 lft.appendChild(fsel); 14281 bar.appendChild(lft); 14282 var hmr=document.createElement('div'); hmr.style.cssText='display:flex;gap:8px;flex-wrap:wrap;'; 14283 hmr.appendChild(ivbtn('Export','',function(){ invExportModal('loans'); })); 14284 hmr.appendChild(ivbtn('+ Loan','pri',function(){ openLoanForm(); })); 14285 bar.appendChild(hmr); 14286 root.appendChild(bar); 14287 14288 if(!ILOAN.length){ 14289 var e=document.createElement('div'); e.style.cssText='padding:44px;text-align:center;color:'+IVC.mut+';'; 14290 e.innerHTML='No loans yet. Add one, or paste the load SQL to import your list.'; 14291 root.appendChild(e); return; 14292 } 14293 14294 // Summary 14295 var actives=ILOAN.filter(loanActive); 14296 var princ=actives.reduce(function(s,l){ return s+invNum(l.principal); }, 0); 14297 var moInt=actives.reduce(function(s,l){ return s+invNum(l.monthly_payment); }, 0); 14298 var paidoff=ILOAN.filter(function(l){ return !loanActive(l); }).length; 14299 var grid=document.createElement('div'); grid.style.cssText='display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:16px;'; 14300 function tile(lbl,val,color){ var d=document.createElement('div'); d.style.cssText='background:'+IVC.tile+';border-radius:10px;padding:14px 16px;'; d.innerHTML='<div style="font-size:13px;color:'+IVC.mut+';">'+lbl+'</div><div style="font-size:24px;font-weight:500;margin-top:3px;color:'+(color||IVC.txt)+';">'+val+'</div>'; return d; } 14301 grid.appendChild(tile('Principal out', invMoney(princ), IVC.txt)); 14302 grid.appendChild(tile('Monthly interest', invMoney(moInt), IVC.grn)); 14303 grid.appendChild(tile('Active loans', String(actives.length), IVC.txt)); 14304 grid.appendChild(tile('Paid off', String(paidoff), IVC.mut)); 14305 root.appendChild(grid); 14306 14307 // Loan table 14308 var list=ILOAN.filter(function(l){ return _hmFilter==='all' || (_hmFilter==='active'?loanActive(l):!loanActive(l)); }); 14309 list.sort(function(a,b){ var ax=loanActive(a)?0:1, bx=loanActive(b)?0:1; if(ax!==bx) return ax-bx; return String(a.end_date||'').localeCompare(String(b.end_date||'')); }); 14310 var tbl=document.createElement('table'); 14311 tbl.innerHTML='<thead><tr><th>Borrower</th><th style="text-align:right;width:96px;">Principal</th><th style="text-align:right;width:80px;">Monthly</th><th style="width:150px;">Term</th><th style="width:82px;">Status</th></tr></thead>'; 14312 var tb=document.createElement('tbody'); 14313 list.forEach(function(l){ 14314 var tr=document.createElement('tr'); tr.style.cursor='pointer'; 14315 var stColor = loanActive(l) ? IVC.grn : IVC.mut; 14316 var term=(l.start_date?fd(l.start_date):'?')+' â '+(l.end_date?fd(l.end_date):'?'); 14317 tr.innerHTML='<td>'+_esc(l.borrower||'?')+'</td>' 14318 +'<td style="text-align:right;">'+invMoney(l.principal)+'</td>' 14319 +'<td style="text-align:right;">'+(invNum(l.monthly_payment)>0?invMoney(l.monthly_payment):'â')+'</td>' 14320 +'<td style="color:'+IVC.mut+';">'+_esc(term)+'</td>' 14321 +'<td style="color:'+stColor+';">'+_esc(l.status||'active')+'</td>'; 14322 (function(id){ tr.addEventListener('click',function(){ openLoanDetail(id); }); })(l.id); 14323 tb.appendChild(tr); 14324 }); 14325 tbl.appendChild(tb); root.appendChild(tbl); 14326} 14327 14328function openLoanForm(loan){ 14329 var statusOpts=[{value:'active',label:'Active'},{value:'paid off',label:'Paid off'},{value:'default',label:'In default'}]; 14330 invOpenForm(loan?'Edit loan':'Add loan', [ 14331 {key:'borrower',label:'Borrower',required:true}, 14332 {key:'address',label:'Secured property / address'}, 14333 {key:'principal',label:'Principal',type:'number'}, 14334 {key:'interest_rate',label:'Interest rate (annual %)',type:'number'}, 14335 {key:'monthly_payment',label:'Monthly payment',type:'number'}, 14336 {key:'term_months',label:'Term (months)',type:'number'}, 14337 {key:'start_date',label:'Start date',type:'date'}, 14338 {key:'end_date',label:'Maturity date',type:'date'}, 14339 {key:'first_payment_date',label:'First payment date',type:'date'}, 14340 {key:'status',label:'Status',type:'select',options:statusOpts,def:'active'}, 14341 {key:'notes',label:'Notes',type:'textarea'} 14342 ], loan, function(v){ 14343 if(!v.borrower.trim()){ alert('Enter a borrower name.'); return false; } 14344 var rec = loan || { id: Date.now()+Math.floor(Math.random()*100000) }; 14345 rec.borrower=v.borrower.trim(); rec.address=v.address.trim(); rec.principal=invNum(v.principal); rec.interest_rate=invNum(v.interest_rate); 14346 rec.monthly_payment=invNum(v.monthly_payment); rec.term_months=v.term_months?parseInt(v.term_months):null; 14347 rec.start_date=v.start_date; rec.end_date=v.end_date; rec.first_payment_date=v.first_payment_date; rec.status=v.status||'active'; rec.notes=v.notes.trim(); 14348 if(!loan) ILOAN.push(rec); saveLoan(rec); 14349 if(loan) openLoanDetail(rec.id); else renderHardMoney(); 14350 }); 14351} 14352function openLoanPayForm(loanId){ 14353 var l=invLoan(loanId); 14354 invOpenForm('Log payment', [ 14355 {key:'date',label:'Date received',type:'date',required:true}, 14356 {key:'amount',label:'Amount',type:'number',required:true}, 14357 {key:'note',label:'Note',type:'textarea'} 14358 ], { date:tod(), amount:(l?l.monthly_payment:'') }, function(v){ 14359 if(invNum(v.amount)<=0){ alert('Enter an amount.'); return false; } 14360 var rec={ id:Date.now()+Math.floor(Math.random()*100000), loan_id:loanId, date:v.date||tod(), amount:invNum(v.amount), note:v.note.trim() }; 14361 ILPAY.push(rec); saveLoanPay(rec); openLoanDetail(loanId); 14362 }); 14363} 14364 14365function openLoanDetail(id){ 14366 _ivStyle(); 14367 var root=ge('hmRoot'); if(!root) return; root.innerHTML='';
14368 var l=invLoan(id); if(!l){ renderHardMoney(); return; } 14369 14370 var top=document.createElement('div'); top.style.cssText='display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:14px;'; 14371 top.appendChild(ivbtn('â¹ Back','',function(){ renderHardMoney(); })); 14372 var ta=document.createElement('div'); ta.style.cssText='display:flex;gap:6px;flex-wrap:wrap;'; 14373 ta.appendChild(ivbtn('Log payment','pri sm',function(){ openLoanPayForm(id); })); 14374 ta.appendChild(ivbtn('Edit','sm',function(){ openLoanForm(l); })); 14375 ta.appendChild(ivbtn('Delete','sm danger',function(){ if(confirm('Delete this loan and its payments?')){ delLoan(id); renderHardMoney(); } })); 14376 top.appendChild(ta); root.appendChild(top); 14377 14378 root.appendChild(mkDivSafe('font-size:22px;font-weight:500;', _esc(l.borrower||'Loan'))); 14379 if(l.address) root.appendChild(mkDivSafe('font-size:14px;color:'+IVC.mut+';margin-bottom:6px;', _esc(l.address))); 14380 14381 // facts 14382 var facts=[]; 14383 facts.push(['Principal', invMoney(l.principal)+(invNum(l.interest_rate)>0?(' · '+l.interest_rate+'%/yr'):'')]); 14384 facts.push(['Monthly payment', invNum(l.monthly_payment)>0?invMoney(l.monthly_payment):'â']); 14385 facts.push(['Term', (l.term_months?l.term_months+' months':'â')+' · '+(l.start_date?fd(l.start_date):'?')+' â '+(l.end_date?fd(l.end_date):'?')]); 14386 facts.push(['Status', l.status||'active']); 14387 if(l.notes) facts.push(['Notes', _esc(l.notes)]); 14388 var fg=document.createElement('div'); fg.style.cssText='background:'+IVC.card+';border:1px solid '+IVC.bord+';border-radius:12px;padding:12px 16px;margin:10px 0 16px;'; 14389 facts.forEach(function(f,i){ var r=document.createElement('div'); r.style.cssText='padding:6px 0;'+(i?'border-top:1px solid '+IVC.bord+';':''); r.innerHTML='<span style="color:'+IVC.mut+';font-size:13px;">'+f[0]+'</span><div style="margin-top:2px;">'+f[1]+'</div>'; fg.appendChild(r); }); 14390 root.appendChild(fg); 14391 14392 // payment schedule 14393 var sched=loanSchedule(l); 14394 var paidMap=loanPaidByMonth(id); 14395 var received=loanReceived(id); 14396 var expected=sched.length*invNum(l.monthly_payment); 14397 root.appendChild(mkDivSafe('font-size:13px;color:'+IVC.mut+';margin:2px 0 6px;','Payment schedule â received '+invMoney(received)+(expected>0?(' of '+invMoney(expected)):''))); 14398 if(!sched.length){ 14399 root.appendChild(mkDivSafe('color:'+IVC.mut+';font-size:14px;padding:6px 0;','Set a first-payment date, term, and monthly payment to generate a schedule. You can still log payments manually.')); 14400 } else { 14401 var tbl=document.createElement('table'); 14402 tbl.innerHTML='<thead><tr><th style="width:90px;">Due</th><th style="text-align:right;width:96px;">Amount</th><th style="width:90px;">Paid</th></tr></thead>'; 14403 var tb=document.createElement('tbody'); 14404 sched.forEach(function(dt){ 14405 var mk=String(dt).slice(0,7); var pay=paidMap[mk]; 14406 var tr=document.createElement('tr'); 14407 var td3=document.createElement('td'); 14408 var btn=document.createElement('button'); btn.className='ivbtn sm'; btn.textContent=pay?'â Paid':'Mark paid'; 14409 if(pay) btn.style.color=IVC.grn; 14410 (function(d2,p2){ btn.addEventListener('click',function(){ 14411 if(p2){ delLoanPay(p2.id); } 14412 else { var rec={ id:Date.now()+Math.floor(Math.random()*100000), loan_id:id, date:d2, amount:invNum(l.monthly_payment), note:'' }; ILPAY.push(rec); saveLoanPay(rec); } 14413 openLoanDetail(id); 14414 }); })(dt, pay); 14415 td3.appendChild(btn); 14416 tr.innerHTML='<td>'+_esc(fd(dt))+'</td><td style="text-align:right;color:'+(pay?IVC.grn:IVC.mut)+';">'+invMoney(l.monthly_payment)+'</td>'; 14417 tr.appendChild(td3); 14418 tb.appendChild(tr); 14419 }); 14420 tbl.appendChild(tb); root.appendChild(tbl); 14421 } 14422 14423 // any ad-hoc payments not on a scheduled month 14424 var extra=paymentsForLoan(id).filter(function(p){ return sched.indexOf(String(p.date).slice(0,10))<0 && !loanSchedule(l).some(function(d){return String(d).slice(0,7)===String(p.date).slice(0,7);}); }); 14425 if(extra.length){ 14426 root.appendChild(mkDivSafe('font-size:13px;color:'+IVC.mut+';margin:14px 0 6px;','Other payments')); 14427 extra.forEach(function(p){ 14428 var r=document.createElement('div'); r.style.cssText='display:flex;justify-content:space-between;align-items:center;background:'+IVC.card+';border:1px solid '+IVC.bord+';border-radius:8px;padding:8px 12px;margin-bottom:6px;'; 14429 r.innerHTML='<span>'+_esc(fd(p.date))+' · '+invMoney(p.amount)+(p.note?(' · '+_esc(p.note)):'')+'</span>'; 14430 var x=ivbtn('Del','sm danger',(function(pid){return function(){ delLoanPay(pid); openLoanDetail(id); };})(p.id)); 14431 r.appendChild(x); root.appendChild(r); 14432 }); 14433 } 14434}
14435// ---- On-demand export / email (properties + hard money) ---- 14436function _csvCell(v){ v=(v==null?'':String(v)); return /[",\n]/.test(v)?('"'+v.replace(/"/g,'""')+'"'):v; } 14437function _downloadFile(name, content, mime){ 14438 try{ 14439 var blob=new Blob([content],{type:mime||'text/csv;charset=utf-8'}); 14440 var url=URL.createObjectURL(blob); var a=document.createElement('a'); a.href=url; a.download=name; document.body.appendChild(a); a.click(); 14441 setTimeout(function(){ try{ URL.revokeObjectURL(url); document.body.removeChild(a); }catch(e){} }, 200); return true; 14442 }catch(e){ alert('Download not supported here â use Email instead. ('+e.message+')'); return false; } 14443} 14444function _inRange(dstr, from, to){ dstr=String(dstr||'').slice(0,10); if(from && dstr<from) return false; if(to && dstr>to) return false; return true; } 14445function buildPropCsv(from, to, pids){ 14446 var rows=[['Date','Property','Unit','HOA','Category','Direction','Amount','Payee','Source','Notes'].join(',')]; 14447 ILED.filter(function(l){ return _inRange(l.date,from,to) && (!pids.length || pids.indexOf(String(l.property_id))>=0); }) 14448 .sort(function(a,b){ return String(a.date).localeCompare(String(b.date)); }) 14449 .forEach(function(l){ 14450 var p=l.property_id?invProp(l.property_id):null, u=l.unit_id?invUnit(l.unit_id):null, h=l.hoa_id?invHoa(l.hoa_id):null; 14451 rows.push([_csvCell(l.date), _csvCell(p?(p.name||p.address):''), _csvCell(u?u.label:''), _csvCell(h?h.name:''), _csvCell(l.category), _csvCell(l.direction||invDirFor(l.category)), invNum(l.amount).toFixed(2), _csvCell(l.payee), _csvCell(l.source), _csvCell(l.notes)].join(',')); 14452 }); 14453 return rows.join('\r\n'); 14454} 14455function buildLoanCsv(from, to, ids){ 14456 var rows=[['Borrower','Address','Principal','Rate %','Term (mo)','Start','Maturity','Monthly','Status','Received (range)','Notes'].join(',')]; 14457 ILOAN.filter(function(l){ return !ids.length || ids.indexOf(String(l.id))>=0; }).forEach(function(l){ 14458 var rec=paymentsForLoan(l.id).filter(function(p){ return _inRange(p.date,from,to); }).reduce(function(s,p){ return s+invNum(p.amount); },0); 14459 rows.push([_csvCell(l.borrower), _csvCell(l.address), invNum(l.principal).toFixed(2), _csvCell(l.interest_rate||''), _csvCell(l.term_months||''), _csvCell(l.start_date||''), _csvCell(l.end_date||''), invNum(l.monthly_payment).toFixed(2), _csvCell(l.status||'active'), rec.toFixed(2), _csvCell(l.notes)].join(',')); 14460 }); 14461 return rows.join('\r\n'); 14462} 14463function invExportModal(kind){ 14464 var isLoan = kind==='loans'; 14465 var ov=document.createElement('div'); ov.className='modal-ov open'; ov.style.zIndex='1300'; 14466 var m=document.createElement('div'); m.className='modal'; m.style.cssText='max-width:560px;background:'+IVC.card+';color:'+IVC.txt+';border:1px solid '+IVC.bord+';'; 14467 m.innerHTML='<div style="padding:16px 20px;border-bottom:1px solid '+IVC.bord+';font-weight:500;font-size:17px;">Export '+(isLoan?'hard money':'properties')+'</div>'; 14468 var body=document.createElement('div'); body.style.cssText='padding:16px 20px;max-height:66vh;overflow:auto;display:flex;flex-direction:column;gap:12px;'; m.appendChild(body); 14469 var inStyle='background:'+IVC.tile+';border:1px solid '+IVC.bord+';color:'+IVC.txt+';border-radius:7px;padding:6px 9px;font-family:inherit;font-size:14px;'; 14470 var rr=document.createElement('div'); rr.style.cssText='display:flex;gap:8px;align-items:center;flex-wrap:wrap;'; 14471 rr.appendChild(mkDivSafe('font-size:13px;color:'+IVC.mut+';', isLoan?'Payments from':'From')); 14472 var fI=document.createElement('input'); fI.type='date'; fI.style.cssText=inStyle; rr.appendChild(fI); 14473 rr.appendChild(mkDivSafe('font-size:13px;color:'+IVC.mut+';','to')); 14474 var tI=document.createElement('input'); tI.type='date'; tI.style.cssText=inStyle; rr.appendChild(tI); 14475 body.appendChild(rr); 14476 body.appendChild(mkDivSafe('font-size:13px;color:'+IVC.mut+';', isLoan?'Loans (leave all unchecked = all)':'Properties (leave all unchecked = all)')); 14477 var selWrap=document.createElement('div'); selWrap.style.cssText='display:flex;flex-direction:column;gap:5px;max-height:170px;overflow:auto;'; 14478 var items = isLoan ? ILOAN.map(function(l){return {id:String(l.id), label:l.borrower};}) : IPROP.map(function(p){return {id:String(p.id), label:(p.name||p.address)};}); 14479 var checks={};
14480 items.forEach(function(it){ var lb=document.createElement('label'); lb.style.cssText='display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer;'; var cb=document.createElement('input'); cb.type='checkbox'; cb.style.cssText='width:16px;height:16px;'; checks[it.id]=cb; lb.appendChild(cb); lb.appendChild(document.createTextNode(it.label||'?')); selWrap.appendChild(lb); }); 14481 body.appendChild(selWrap); 14482 body.appendChild(mkDivSafe('font-size:13px;color:'+IVC.mut+';','Email to')); 14483 var toIn=document.createElement('input'); toIn.type='text'; toIn.value='[email protected], [email protected]'; toIn.style.cssText=inStyle; body.appendChild(toIn); 14484 var ft=document.createElement('div'); ft.style.cssText='display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid '+IVC.bord+';'; 14485 function mb(label,primary){ var b=document.createElement('button'); b.textContent=label; b.style.cssText='background:'+(primary?IVC.accent:'transparent')+';border:1px solid '+(primary?IVC.accent:IVC.bord)+';color:'+IVC.txt+';border-radius:7px;padding:7px 14px;font-size:14px;cursor:pointer;font-family:inherit;'; return b; } 14486 function selIds(){ return Object.keys(checks).filter(function(k){return checks[k].checked;}); } 14487 function csv(){ return isLoan ? buildLoanCsv(fI.value,tI.value,selIds()) : buildPropCsv(fI.value,tI.value,selIds()); } 14488 function fname(){ return (isLoan?'hard-money':'properties')+'-'+(new Date().toISOString().slice(0,10))+'.csv'; } 14489 var dl=mb('Download CSV'); dl.addEventListener('click',function(){ _downloadFile(fname(), csv()); }); 14490 var em=mb('Email now', true); 14491 em.addEventListener('click',async function(){ 14492 var recips=toIn.value.split(/[,;]/).map(function(s){return s.trim();}).filter(function(s){return s.indexOf('@')>0;}); 14493 if(!recips.length){ alert('Enter at least one email address.'); return; } 14494 em.textContent='Sendingâ¦'; em.disabled=true; 14495 try{ 14496 var resp=await fetch('/api/send-csv',{ method:'POST', headers:await apiHeaders(), body:JSON.stringify({ to:recips, subject:(isLoan?'Hard money export':'Properties export')+' â '+fname(), filename:fname(), csv:csv() }) }); 14497 var d=await resp.json(); 14498 if(d.ok){ alert('Sent to '+recips.join(', ')); try{document.body.removeChild(ov);}catch(e){} } 14499 else { alert('Could not send: '+(d.error||'unknown')); em.textContent='Email now'; em.disabled=false; } 14500 }catch(e){ alert('Send failed: '+e.message); em.textContent='Email now'; em.disabled=false; } 14501 }); 14502 var cl=mb('Close'); cl.addEventListener('click',function(){ try{document.body.removeChild(ov);}catch(e){} }); 14503 ft.appendChild(dl); ft.appendChild(em); ft.appendChild(cl); m.appendChild(ft); 14504 ov.appendChild(m); ov.addEventListener('click',function(e){ if(e.target===ov) try{document.body.removeChild(ov);}catch(_){} }); 14505 document.body.appendChild(ov); 14506} 14507// ===================== END INVESTMENTS MODULE ===================== 14508})();
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.