PageSourceSearch

https://properly-crm.vercel.app/app.js?v=20260923a

js properly-crm.vercel.app collected 2026-10-03 07:32:54 UTC 889,397 bytes, 14,508 lines download raw bytes

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;">&#8250;</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:'&#128100;', color:'#4C8EC9', id:'cf_first'},
5617    {label:'Last Name', key:'lastName', icon:'&#128100;', color:'#4C8EC9', id:'cf_last'},
5618    {label:'Job Title', key:'title', icon:'&#128084;', color:'#C9A84C', id:'cf_title'},
5619    {label:'Company', key:'company', icon:'&#127970;', color:'#C9A84C', id:'cf_company'},
5620    {label:'Phone', key:'phone', icon:'&#128222;', color:'#6BC97A', id:'cf_phone'},
5621    {label:'Mobile', key:'mobile', icon:'&#128241;', color:'#6BC97A', id:'cf_mobile'},
5622    {label:'Phone 2', key:'phone2', icon:'&#128222;', color:'#6BC97A', id:'cf_phone2'},
5623    {label:'Email', key:'email', icon:'&#9993;', color:'#C94C8E', id:'cf_email'},
5624    {label:'Email 2', key:'email2', icon:'&#9993;', color:'#C94C8E', id:'cf_email2'},
5625    {label:'Website', key:'website', icon:'&#127760;', color:'#4C8EC9', id:'cf_website'},
5626    {label:'Address', key:'address', icon:'&#128205;', color:'#C96B4C', id:'cf_address'},
5627    {label:'LinkedIn', key:'linkedin', icon:'in', color:'#0077b5', id:'cf_linkedin'},
5628    {label:'Instagram', key:'instagram', icon:'&#128247;', 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:'&#9835;', color:'#ff0050', id:'cf_tiktok'},
5632    {label:'WhatsApp', key:'whatsapp', icon:'&#128172;', color:'#25D366', id:'cf_whatsapp'},
5633    {label:'Notes', key:'notes', icon:'&#128203;', 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);"> &rarr; </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&&currentUser.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 = '&#8592; 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>&nbsp;bd</span>'
9778    + '<span class="mls-stat"><b>' + l.baths + '</b>&nbsp;ba</span>'
9779    + '<span class="mls-stat"><b>' + (l.sqft ? l.sqft.toLocaleString() : 'N/A') + '</b>&nbsp;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&&currentUser.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&&currentUser.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;">&#9650;</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(' &middot; ')||'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='&bull; '+_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 &amp; 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))+'&ndash;'+_loiM(dsNum(d.camHighPerSF))+'/SF annually, or approximately '+_loiM(c.camLoMo)+'&ndash;'+_loiM(c.camHiMo)+' per month ('+_loiM(c.camLoAnn)+'&ndash;'+_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&rsquo;s monthly share before lease execution.');
13704    row('Estimated Total Occupancy Cost', 'Approximately '+_loiM(c.totLoMo)+'&ndash;'+_loiM(c.totHiMo)+' per month ('+_loiM(c.totLoAnn)+'&ndash;'+_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&rsquo;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&rsquo;s inspection and approval.");
13710  row('Rent Commencement', "The later of "+_esc(d.rentCommence||'')+", or Landlord&rsquo;s delivery of the Premises in the required condition, following satisfaction or waiver of Tenant&rsquo;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 &ldquo;Due Diligence Period&rdquo;) to complete, at Tenant&rsquo;s expense, all zoning verification, conditional-use approval, parking-ratio confirmation, and Certificate of Occupancy requirements with the applicable municipality for Tenant&rsquo;s intended use. Landlord will reasonably cooperate with and, where required, co-sign or authorize Tenant&rsquo;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&rsquo;s base rent, will be paid upon execution of the lease together with any other amounts then due. Any first month&rsquo;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&rsquo;s parking areas and 24-hour access to the Premises, subject to reasonable rules that do not materially interfere with Tenant&rsquo;s use.");
13717  if(d.signageOn) row('Signage', "Tenant may install code-compliant building, storefront, monument/panel, and door signage, subject to Landlord&rsquo;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 &bull; '+_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 &ldquo;Premises&rdquo;), 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 (&ldquo;LOI&rdquo;) 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&rsquo;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&rsquo;s satisfactory inspection of the Premises and building systems, review of plans and measurements, and confirmation of ADA/accessibility requirements applicable to Tenant&rsquo;s use.</li>'
13750     +'<li>Tenant&rsquo;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&rsquo;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&rsquo;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&rsquo;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">&nbsp;</div><div class="sl">Name: ____________________</div><div class="sn">&nbsp;</div><div class="sl">Title: _____________________</div><div class="sn">&nbsp;</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&rsquo;s Representative / REALTOR&reg;</div>'
13769   +'<div class="sl">Signature: _______________________</div>'
13770   +'<div class="sl">Date: _______________________</div>'
13771   +'<div class="loi-foot">Palacios Baker Real Estate &middot; Utah&rsquo;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 &amp; 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.