AS
Connect Supabase: Use demo data →
Properties 0
🔍
🏢
Active
☀️
Seasonal
📋
With contracts
📡
Smart systems
🔧
Avg zones
Property Address Manager Status Contract Zones Smart Last Service Actions
📐 Field Estimates
Created by technicians on site · click to edit & generate report
No field estimates yet — they appear here when technicians submit them from the Field Manager app.
`; // Open in new tab for print/save var win = window.open('', '_blank'); if (win) { win.document.write(html); win.document.close(); setTimeout(function(){ win.print(); }, 500); } else { // Fallback: create blob and download var blob = new Blob([html], { type: 'text/html' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = 'field-estimate-' + esc(propName).replace(/[^a-z0-9]/gi,'-').toLowerCase() + '-' + dateStr + '.html'; a.click(); URL.revokeObjectURL(url); } } // ── INVOICING ───────────────────────────────────────────── async function markWOInvoiced(id) { var wo = allWorkOrders.find(function(w){ return String(w.id)===String(id); }); if (!wo) return; if (!confirm('Mark "' + (wo.description||'Work Order') + '" as invoiced?')) return; try { var invoicedDate = new Date().toISOString().slice(0,10); var res = await fetch('https://bqimtyqkigewyrwyhjck.supabase.co/rest/v1/work_orders?id=eq.'+id, { method: 'PATCH', headers: {'apikey':sbKey,'Authorization':'Bearer '+sbKey,'Content-Type':'application/json','Prefer':'return=minimal'}, body: JSON.stringify({ status: 'invoiced', notes: ((wo.notes||'') + ' [Invoiced ' + invoicedDate + ']').trim() }) }); if (!res.ok) throw new Error(await res.text()); // Update local cache immediately if (wo) { wo.status = 'invoiced'; wo.notes = ((wo.notes||'') + ' [Invoiced ' + invoicedDate + ']').trim(); } renderWorkOrdersView(); showInvoicedToast(wo.description||'Work Order'); // Also update service history billed_status to 'invoiced' if there's a matching record var matchSH = allServiceHistory.find(function(s){ return s.fm_job_id && wo.fm_todo_id && s.fm_job_id === wo.fm_todo_id; }); if (matchSH) { await fetch('https://bqimtyqkigewyrwyhjck.supabase.co/rest/v1/service_history?id=eq.'+matchSH.id, { method: 'PATCH', headers: {'apikey':sbKey,'Authorization':'Bearer '+sbKey,'Content-Type':'application/json','Prefer':'return=minimal'}, body: JSON.stringify({ billed_status: 'billed' }) }); matchSH.billed_status = 'invoiced'; } } catch(e) { alert('Failed to mark invoiced: ' + e.message); } } function showInvoicedToast(name) { var existing = document.getElementById('invoiced-toast'); if (existing) existing.remove(); var toast = document.createElement('div'); toast.id = 'invoiced-toast'; toast.style.cssText = 'position:fixed;bottom:20px;right:20px;background:#185FA5;color:#fff;padding:10px 18px;border-radius:8px;font-size:13px;font-weight:500;font-family:var(--font);z-index:9999;box-shadow:0 4px 12px rgba(0,0,0,.2);animation:slideIn .2s ease-out'; toast.innerHTML = '💵 Marked as invoiced: ' + name.slice(0,40) + ''; document.body.appendChild(toast); setTimeout(function(){ if(toast.parentNode) toast.remove(); }, 3500); } // ── SERVICE HISTORY — INVOICE TOGGLE ───────────────────── async function markSHInvoiced(id) { try { var res = await fetch('https://bqimtyqkigewyrwyhjck.supabase.co/rest/v1/service_history?id=eq.'+id, { method:'PATCH', headers:{'apikey':sbKey,'Authorization':'Bearer '+sbKey,'Content-Type':'application/json','Prefer':'return=minimal'}, body: JSON.stringify({ billed_status: 'billed' }) }); if (!res.ok) throw new Error(await res.text()); var rec = allServiceHistory.find(function(s){ return String(s.id)===String(id); }); if (rec) rec.billed_status = 'billed'; renderServiceHistoryView(); if (currentProperty) loadPropertyPageData(String(currentProperty.id)); } catch(e) { alert('Failed: '+e.message); } } // Manual trigger for FM report sync — wraps the auto-sync with UI feedback async function syncFMReportsNow() { var btn = document.querySelector('[onclick="syncFMReportsNow()"]'); if (btn) { btn.disabled=true; btn.textContent='⏳ Syncing…'; } try { await syncFMCompletedJobs(); } finally { if (btn) { btn.disabled=false; btn.textContent='↻ Sync FM Reports'; } } } // ── SCHEMA MIGRATION ───────────────────────────────────── // Ensures fm_report_id and fm_job_id columns exist in service_history // Runs once after login, safe to call multiple times async function ensureSHColumns() { if (!sbKey || !sbUrl) return; try { // Try a probe read — if the column exists this returns fine var probe = await fetch('https://bqimtyqkigewyrwyhjck.supabase.co/rest/v1/service_history?select=fm_report_id,fm_job_id&limit=1', { headers: {'apikey':sbKey,'Authorization':'Bearer '+sbKey,'Content-Type':'application/json'} }); if (probe.ok) { console.log('[IOS] service_history.fm_report_id + fm_job_id columns confirmed ✅'); return; // columns exist — nothing to do } // Columns missing — log the SQL needed console.warn('[IOS] service_history is missing fm_report_id/fm_job_id columns.'); console.warn('[IOS] Run this SQL in your Supabase dashboard to fix permanently:'); console.warn(' ALTER TABLE service_history ADD COLUMN IF NOT EXISTS fm_report_id text;'); console.warn(' ALTER TABLE service_history ADD COLUMN IF NOT EXISTS fm_job_id text;'); console.warn(' CREATE UNIQUE INDEX IF NOT EXISTS uq_sh_fm_report ON service_history(fm_report_id) WHERE fm_report_id IS NOT NULL;'); console.warn('[IOS] Until then, sync will work but deduplication uses visit_date+job_id.'); } catch(e) { /* non-critical */ } } // ── IOS ↔ FIELD MANAGER SYNC ───────────────────────────── var IOS_URL = 'https://bqimtyqkigewyrwyhjck.supabase.co'; var _fmSyncTimer = null; var _fmLastPoll = 0; var _fmSyncRunning = false; // Send a work order to FM — writes to Supabase so FM can poll it async function sendWOToFieldManager(id) { var w = allWorkOrders.find(function(x){ return String(x.id)===String(id); }); if (!w) { alert('Work order not found.'); return; } var prop = allProperties.find(function(p){ return String(p.id)===String(w.property_id); }) || {}; var prioMatch = ((w.notes||'').match(/^\[(LOW|MEDIUM|HIGH|URGENT)\]/i)||[])[1]||'medium'; var cleanNotes = (w.notes||'').replace(/^\[(LOW|MEDIUM|HIGH|URGENT)\]\s*/i,''); // 1. Set ios_push_pending flag on the work order row so FM can poll it try { var patchRes = await fetch(IOS_URL+'/rest/v1/work_orders?id=eq.'+id, { method: 'PATCH', headers: {'apikey':sbKey,'Authorization':'Bearer '+sbKey,'Content-Type':'application/json','Prefer':'return=minimal'}, body: JSON.stringify({ ios_push_pending: true }) }); if (!patchRes.ok) throw new Error(await patchRes.text()); // Update local cache if (w) { w.ios_push_pending = true; } // Re-render immediately so ticker updates without waiting for reload renderWorkOrdersView(); } catch(e) { console.warn('FM sync flag failed:', e.message); } // 2. Also write to affinity_v3 localStorage for same-device FM access try { var existing = JSON.parse(localStorage.getItem('affinity_v3')||'{}'); if (!existing.jobs) existing.jobs = []; var already = existing.jobs.find(function(j){ return j.source_id === String(w.id); }); if (already) { if (!confirm('"'+(w.description||'This job')+'" is already in Field Manager. Send again?')) return; } existing.jobs.push({ id: 'wo_'+Date.now(), source_id: String(w.id), source: 'affinity_ios', ios_wo_id: String(w.id), property_id: w.property_id, property: prop.property_name||'—', address: prop.address||'', job_name: w.description||'Work Order', job_type: w.job_type||'', priority: prioMatch.toLowerCase(), scheduled_date: w.scheduled_date||'', technician: w.assigned_crew||'', notes: cleanNotes, status: 'approved', created_at: new Date().toISOString() }); localStorage.setItem('affinity_v3', JSON.stringify(existing)); } catch(e) { console.warn('FM localStorage write failed:', e.message); } // Visual feedback var btn = document.getElementById('fm-btn-'+id); if (btn) { btn.textContent='✓ Sent'; btn.style.background='#1A7A45'; setTimeout(function(){ btn.textContent='→ FM'; btn.style.background='#0A7C6A'; }, 2500); } } // Poll FM state for completed jobs → write to IOS work_orders + service_history async function syncFMCompletedJobs() { if (_fmSyncRunning) { console.log('[SYNC] skipped — already running'); return; } _fmSyncRunning = true; console.log('[SYNC] ===== syncFMCompletedJobs START ====='); try { var FM_URL = 'https://qmuvdxiusylpwexteuas.supabase.co'; var FM_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InFtdXZkeGl1c3lscHdleHRldWFzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzE5ODA0MjksImV4cCI6MjA4NzU1NjQyOX0.r3iLzYllpRIj21RvTAj1XHrqf17hTdVmmTu6JUSiy44'; // STEP 1: Fetch FM app_state console.log('[SYNC] Step 1: fetching FM app_state...'); var res = await fetch(FM_URL+'/rest/v1/app_state?id=eq.affinity_fm&select=state,updated_at', { headers: {'apikey':FM_KEY,'Authorization':'Bearer '+FM_KEY,'Content-Type':'application/json'} }); console.log('[SYNC] Step 1 response:', res.status, res.ok); if (!res.ok) { console.warn('[SYNC] ABORT: FM app_state fetch failed', res.status); return; } var rows = await res.json(); console.log('[SYNC] Step 1 rows:', rows.length, rows[0] ? 'has state:' + !!rows[0].state : 'empty'); if (!rows.length || !rows[0].state) { console.warn('[SYNC] ABORT: no FM state data'); return; } var fmState; try { fmState = JSON.parse(rows[0].state); } catch(e) { console.warn('[SYNC] ABORT: FM state JSON parse error:', e.message); return; } var reports = fmState.reports || []; var allFmJobs = fmState.jobs || []; var clients = fmState.clients || []; console.log('[SYNC] FM state has:', reports.length, 'reports,', allFmJobs.length, 'jobs,', clients.length, 'clients'); if (!reports.length) { console.warn('[SYNC] ABORT: FM has 0 reports — nothing to sync'); return; } // Build job map var fmJobMap = {}; allFmJobs.forEach(function(j){ fmJobMap[j.id] = j; }); // STEP 2: Dedup check console.log('[SYNC] Step 2: checking existing service_history records...'); console.log('[SYNC] IOS_URL:', IOS_URL, '| sbKey length:', (sbKey||'').length); var syncedIds = new Set(); try { var dedupRes = await fetch(IOS_URL+'/rest/v1/service_history?select=id,fm_report_id,fm_job_id,visit_date&limit=500', { headers: {'apikey':sbKey,'Authorization':'Bearer '+sbKey,'Content-Type':'application/json'} }); console.log('[SYNC] Step 2 dedup response:', dedupRes.status, dedupRes.ok); if (dedupRes.ok) { var existing = await dedupRes.json(); console.log('[SYNC] Step 2 existing SH records:', existing.length); existing.forEach(function(r){ if (r.fm_report_id) syncedIds.add(r.fm_report_id); if (r.fm_job_id) syncedIds.add('job:'+r.fm_job_id); }); } else { var dedupErr = await dedupRes.text(); console.warn('[SYNC] Step 2 dedup query failed:', dedupRes.status, dedupErr.slice(0,200)); } } catch(e) { console.warn('[SYNC] Step 2 dedup exception:', e.message); } // Filter to unseen reports var newReports = reports.filter(function(r){ return !syncedIds.has(r.id) && !syncedIds.has('job:'+r.jobId); }); console.log('[SYNC] Reports to sync:', newReports.length, 'of', reports.length, 'total'); if (!newReports.length) { console.log('[SYNC] DONE: all reports already synced'); return; } // Log first report structure if (newReports[0]) { var r0 = newReports[0]; console.log('[SYNC] Sample report:', {id:r0.id, jobId:r0.jobId, client:r0.client, type:r0.type, completedAt:r0.completedAt}); } // STEP 3: Process each report var succeeded = 0, failed = 0; for (var i = 0; i < newReports.length; i++) { var r = newReports[i]; console.log('[SYNC] Processing report', i+1, 'of', newReports.length, '| id:', r.id, '| client:', r.client); // Property resolution var fmJob = fmJobMap[r.jobId] || null; var iosWoId = fmJob ? (fmJob.ios_wo_id || fmJob.source_id || null) : null; var wo = iosWoId ? allWorkOrders.find(function(w){ return String(w.id)===String(iosWoId); }) : null; var propId = wo ? wo.property_id : null; // Try FM client ios_property_id var fmClient = null; if (!propId && fmJob && fmJob.clientId) { fmClient = clients.find(function(c){ return c.id === fmJob.clientId; }); if (fmClient) { propId = fmClient.ios_property_id || null; console.log('[SYNC] client:', fmClient.name, '| ios_property_id:', fmClient.ios_property_id); } } // Fuzzy match fallback var prop = propId ? allProperties.find(function(p){ return String(p.id)===String(propId); }) : allProperties.find(function(p){ var pn = (p.property_name||'').toLowerCase(); var rc = (r.client||'').toLowerCase(); return pn===rc || rc.includes(pn) || pn.includes(rc); }); var finalPropId = (prop ? prop.id : propId) || null; console.log('[SYNC] resolved property_id:', finalPropId, prop ? '('+prop.property_name+')' : '(no match)'); // Build work_performed var remarksText = ''; if (r.remarks) { if (Array.isArray(r.remarks)) remarksText = r.remarks.join(', '); else if (typeof r.remarks === 'object') remarksText = Object.values(r.remarks).flat().join(', '); else remarksText = String(r.remarks); } var workPerformed = [ r.type ? 'Service type: '+r.type : '', remarksText ? 'Remarks: '+remarksText : '', r.siteTime ? 'On site: '+r.siteTime : '', r.driveTime ? 'Drive: '+r.driveTime : '', r.parts && r.parts.length ? 'Parts: '+r.parts.map(function(p){return p.name+'x'+p.qty;}).join(', ') : '' ].filter(Boolean).join(' | '); var visitDate = r.completedAt ? new Date(r.completedAt).toISOString().slice(0,10) : new Date().toISOString().slice(0,10); // POST to service_history — minimal payload first (no FM columns) var shPayload = { ios_property_id: finalPropId || null, visit_date: visitDate, service_date: visitDate, service_type: r.type || 'Field Service', original_job_type: r.type || null, technician_name: r.techName || r.tech || null, technician_id: r.techId || null, billed_status: 'unbilled', status: 'completed', remarks: workPerformed || null, notes: r.additionalNotes || null, parts_used: (r.parts && r.parts.length) ? r.parts.map(function(p){ return p.name+' x'+p.qty; }).join(', ') : null, parts_json: (r.parts && r.parts.length) ? r.parts : null, site_time: r.siteTime || null, drive_time: r.driveTime || null, actual_site_minutes: r.actualSiteMinutes || null, fm_report_id: r.id || null, fm_job_id: r.jobId || null, fm_visit_id: r.visitId || null, property_name: r.client || null, property_addr: r.addr || null, synced_at: new Date().toISOString() }; // Try to add FM columns if they exist try { shPayload.fm_report_id = r.id; shPayload.fm_job_id = r.jobId; } catch(e) {} console.log('[SYNC] POSTing to service_history:', JSON.stringify(shPayload).slice(0,120)+'...'); var shRes = await fetch(IOS_URL+'/rest/v1/service_history', { method: 'POST', headers: {'apikey':sbKey,'Authorization':'Bearer '+sbKey,'Content-Type':'application/json','Prefer':'return=minimal'}, body: JSON.stringify(shPayload) }); if (!shRes.ok) { var shErr = ''; try { shErr = await shRes.text(); } catch(e) {} console.warn('[SYNC] POST FAILED:', shRes.status, shErr.slice(0,300)); // Retry without fm_report_id / fm_job_id if (shErr.includes('PGRST204') || shErr.includes('fm_report_id') || shErr.includes('fm_job_id') || shRes.status === 400) { console.log('[SYNC] Retrying without FM columns...'); delete shPayload.fm_report_id; delete shPayload.fm_job_id; shRes = await fetch(IOS_URL+'/rest/v1/service_history', { method: 'POST', headers: {'apikey':sbKey,'Authorization':'Bearer '+sbKey,'Content-Type':'application/json','Prefer':'return=minimal'}, body: JSON.stringify(shPayload) }); if (!shRes.ok) { var shErr2 = ''; try { shErr2 = await shRes.text(); } catch(e) {} console.warn('[SYNC] RETRY ALSO FAILED:', shRes.status, shErr2.slice(0,300)); failed++; continue; } } else { failed++; continue; } } console.log('[SYNC] POST succeeded ✅'); succeeded++; // Update WO status if linked if (iosWoId) { var woRes = await fetch(IOS_URL+'/rest/v1/work_orders?id=eq.'+iosWoId, { method: 'PATCH', headers: {'apikey':sbKey,'Authorization':'Bearer '+sbKey,'Content-Type':'application/json','Prefer':'return=minimal'}, body: JSON.stringify({ status:'completed', fm_synced_at:new Date().toISOString(), completion_date:visitDate }) }); console.log('[SYNC] WO PATCH:', woRes.status, iosWoId); var localWO = allWorkOrders.find(function(w){ return String(w.id)===String(iosWoId); }); if (localWO) { localWO.status='completed'; localWO.fm_synced_at=new Date().toISOString(); localWO.completion_date=visitDate; } } // Update property last_service_date if (finalPropId) { fetch(IOS_URL+'/rest/v1/properties?id=eq.'+finalPropId, { method:'PATCH', headers:{'apikey':sbKey,'Authorization':'Bearer '+sbKey,'Content-Type':'application/json','Prefer':'return=minimal'}, body:JSON.stringify({last_service_date:visitDate}) }).catch(function(){}); } } console.log('[SYNC] ===== COMPLETE: '+succeeded+' succeeded, '+failed+' failed ====='); if (succeeded > 0) { await loadServiceHistory(); await loadWorkOrders(); renderWorkOrdersView(); showFMSyncBadge(succeeded); } else if (newReports.length > 0) { showFMSyncBadge(0); alert('[FM Sync] All '+newReports.length+' report(s) failed to write. Check browser console (F12) for details.'); } } catch(e) { console.warn('[SYNC] EXCEPTION:', e.message, e.stack); } finally { _fmSyncRunning = false; } } function showFMSyncBadge(count) { var existing = document.getElementById('fm-sync-toast'); if (existing) existing.remove(); var toast = document.createElement('div'); toast.id = 'fm-sync-toast'; toast.style.cssText = 'position:fixed;bottom:20px;right:20px;background:#0A7C6A;color:#fff;padding:10px 16px;border-radius:8px;font-size:13px;font-weight:500;font-family:var(--font);z-index:9999;box-shadow:0 4px 12px rgba(0,0,0,.2);animation:slideIn .2s ease-out'; toast.textContent = '✓ '+count+' completed job'+(count>1?'s':'')+' synced from Field Manager'; document.body.appendChild(toast); setTimeout(function(){ if(toast.parentNode) toast.remove(); }, 4000); } // Start polling FM every 60 seconds when IOS is open function startFMSync() { ensureSHColumns(); // probe for FM columns, log SQL if missing if (_fmSyncTimer) clearInterval(_fmSyncTimer); syncFMCompletedJobs(); // immediate first run _fmSyncTimer = setInterval(syncFMCompletedJobs, 60000); } function sendWorkToFieldManager(id) { var w = dashApproved.find(function(x){return x.id===id;}); if (!w) return; try { // Check for duplicate — don't push same WO twice var already = existing.jobs.find(function(j){ return j.source_id === String(w.id); }); if (already) { if (!confirm('"'+(w.description||'This job')+'" is already in Field Manager. Send again?')) return; } var prop = allProperties.find(function(p){ return String(p.id)===String(w.property_id); }) || {}; // Parse priority back from notes prefix [HIGH] etc. var prioMatch = ((w.notes||'').match(/^\[(LOW|MEDIUM|HIGH|URGENT)\]/i)||[])[1]||'medium'; var cleanNotes = (w.notes||'').replace(/^\[(LOW|MEDIUM|HIGH|URGENT)\]\s*/i,''); existing.jobs.push({ id: 'wo_'+Date.now(), source_id: String(w.id), source: 'affinity_ios', property_id: w.property_id, property: prop.property_name||'—', address: prop.address||'', job_name: w.description||'Work Order', job_type: w.job_type||'', priority: prioMatch.toLowerCase(), scheduled_date: w.scheduled_date||'', technician: w.assigned_crew||'', notes: cleanNotes, status: 'approved', created_at: new Date().toISOString() }); localStorage.setItem('affinity_v3', JSON.stringify(existing)); // Visual feedback — button turns green briefly var btn = document.getElementById('fm-btn-'+id); if (btn) { btn.textContent='✓ Sent'; btn.style.background='#1A7A45'; setTimeout(function(){ btn.textContent='→ Field Manager'; btn.style.background='#0A7C6A'; },2000); } else { alert('Sent to Field Manager: '+(w.description||'Work Order')); } } catch(e) { alert('Field Manager error: '+e.message); } } function sendWorkToFieldManager(id) { var w = dashApproved.find(function(x){return x.id===id;}); if (!w) return; try { var existing = JSON.parse(localStorage.getItem('affinity_v3')||'{}'); if (!existing.jobs) existing.jobs = []; var prop = allProperties.find(function(p){return p.id===w.property_id;}) || {}; existing.jobs.push({ id: 'job_'+Date.now(), property_id:w.property_id, property: prop.property_name||'—', address: prop.address||'', job_name: w.job_name, job_type: w.job_type, priority: w.priority, scheduled_date: w.scheduled_date, technician: w.assigned_technician_name||'', materials: w.materials_required||'', notes: w.notes||'', status:'approved', source:'affinity_ios', created_at:new Date().toISOString() }); localStorage.setItem('affinity_v3', JSON.stringify(existing)); alert('Sent to Field Manager: '+w.job_name); } catch(e) { alert('Field Manager error: '+e.message); } } function sendVisitToFieldManager(id) { var v = dashVisits.find(function(x){return x.id===id;}); if (!v) return; try { var existing = JSON.parse(localStorage.getItem('affinity_v3')||'{}'); if (!existing.jobs) existing.jobs = []; var prop = allProperties.find(function(p){return p.id===v.property_id;}) || {}; existing.jobs.push({ id: 'visit_'+Date.now(), property_id:v.property_id, property: prop.property_name||'—', address: prop.address||'', job_name: v.service_type||'Site Visit', job_type:'site_visit', scheduled_date: v.visit_date, technician: v.technician_name||'', notes: v.recommendations||'', status:'approved', source:'affinity_ios', created_at:new Date().toISOString() }); localStorage.setItem('affinity_v3', JSON.stringify(existing)); alert('Sent to Field Manager: '+(v.service_type||'Site Visit')); } catch(e) { alert('Field Manager error: '+e.message); } } function exportCSV() { const cols = ['property_name','mtcc_tscc_number','address','city','postal_code', 'property_status','property_manager_company','manager_name','manager_email', 'manager_phone','contract_type','active_contract','system_type','controller_type', 'number_of_zones','water_source','smart_system_installed','last_service_date','next_service_date']; const header = cols.join(','); const rows = filtered.map(p => cols.map(c => `"${(p[c]??'').toString().replace(/"/g,'""')}"`).join(',')); const csv = [header,...rows].join('\n'); const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([csv], {type:'text/csv'})); a.download = `Affinity-Properties-${new Date().toISOString().slice(0,10)}.csv`; a.click(); } // ── QUICK FILTER HELPERS ────────────────────────────────── function setStatusFilter(v) { document.getElementById('filter-status').value = v; applyFilters(); } function setSmartFilter(v) { document.getElementById('filter-smart').value = v; applyFilters(); } // ── MODAL CLOSE ─────────────────────────────────────────── function closeModal(e, id) { if (e.target === e.currentTarget) document.getElementById(id).classList.remove('open'); } // ── UTILS ───────────────────────────────────────────────── function esc(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function cap(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1).replace(/_/g,' ') : ''; } function fmtStatus(s) { var labels = { sent:'Sent', follow_up_needed:'Follow-up needed', approved:'Approved', declined:'Declined', drafting:'Drafting', expired:'Expired', active:'Active', draft:'Draft', expiring_soon:'Expiring soon' }; return labels[s] || cap(s) || '—'; } function fmtDate(d) { if (!d) return '—'; const dt = new Date(d + 'T12:00:00'); return dt.toLocaleDateString('en-CA', {year:'numeric',month:'short',day:'numeric'}); } // ── CONTRACTS VIEW ──────────────────────────────────────── var allContractsView = []; async function loadContractsView() { try { var res = await fetch('https://bqimtyqkigewyrwyhjck.supabase.co/rest/v1/contracts?select=*&order=created_at.desc&limit=300', { headers: {'apikey':sbKey,'Authorization':'Bearer '+sbKey,'Content-Type':'application/json'} }); if (!res.ok) throw new Error(await res.text()); var fresh = await res.json(); if (Array.isArray(fresh)) allContractsView = fresh; } catch(e) { console.warn('Contracts load failed — keeping existing data:', e.message); } renderContractsView(); renderBulkImporterTable(); } function renderContractsView() { var sf = document.getElementById('cn-filter-status'); var statusFilter = sf ? sf.value : ''; var rows = allContractsView.filter(function(c){ return !statusFilter || c.status===statusFilter; }); var totalVal = allContractsView.reduce(function(s,c){ return s+(parseFloat(c.contract_value)||0); },0); var active = allContractsView.filter(function(c){ return c.status==='active'||c.status==='approved'; }).length; var signed = allContractsView.filter(function(c){ return !!c.signed_date; }).length; var sr = document.getElementById('contracts-stats-row'); if (sr) sr.innerHTML = [ {label:'Total Contracts', val:allContractsView.length, color:'#0A7C6A', bg:'#E6F4F1'}, {label:'Active / Approved', val:active, color:'#185FA5', bg:'#EAF1FC'}, {label:'Signed', val:signed, color:'#1A7A45', bg:'#E8F7EE'}, {label:'Season Value', val:'$'+totalVal.toLocaleString('en-CA',{minimumFractionDigits:0,maximumFractionDigits:0}), color:'#D4820A', bg:'#FFF4E0'} ].map(function(s){ return '
' +'
'+s.val+'
' +'
'+s.label+'
'; }).join(''); var tbody = document.getElementById('contracts-view-tbody'); var empty = document.getElementById('contracts-view-empty'); if (!rows.length) { if(tbody) tbody.innerHTML=''; if(empty) empty.style.display='block'; return; } if(empty) empty.style.display='none'; var SC={'active':'#1A7A45','approved':'#0A7C6A','sent':'#185FA5','expiring_soon':'#D4820A','draft':'#6B7D7A','expired':'#888'}; var SB={'active':'#E8F7EE','approved':'#E6F4F1','sent':'#EAF1FC','expiring_soon':'#FFF4E0','draft':'#f0f0f0','expired':'#f0f0f0'}; if(tbody) tbody.innerHTML = rows.map(function(c){ var prop = allProperties.find(function(p){ return String(p.id)===String(c.property_id); }); var propName = prop ? prop.property_name : '—'; var sc=SC[c.status]||'#6B7D7A', sb=SB[c.status]||'#f0f0f0'; var val = c.contract_value ? '$'+parseFloat(c.contract_value).toLocaleString('en-CA',{minimumFractionDigits:0,maximumFractionDigits:0}) : 'TBD'; var period = (c.start_date&&c.end_date) ? fmtDate(c.start_date)+' – '+fmtDate(c.end_date) : '—'; var isApproved = c.status === 'approved' || c.status === 'active'; var pdfBadge = c.file_url ? '📎 PDF' : ''; return '' +''+esc(propName)+'' +''+esc(c.contract_name||'')+pdfBadge+'' +''+esc(c.status||'—')+'' +'' +'' +'' +''+val+'' +''+period+'' +''+(c.signed_date?'✓ '+fmtDate(c.signed_date):'Unsigned')+'' +'' +(c.file_url ? '' : '') +'' +'' +''; }).join(''); } async function toggleContractApproval(contractId, approve) { var newStatus = approve ? 'approved' : 'sent'; try { var res = await fetch('https://bqimtyqkigewyrwyhjck.supabase.co/rest/v1/contracts?id=eq.'+contractId, { method: 'PATCH', headers: {'apikey':sbKey,'Authorization':'Bearer '+sbKey,'Content-Type':'application/json','Prefer':'return=minimal'}, body: JSON.stringify({ status: newStatus }) }); if (!res.ok) throw new Error(await res.text()); // Update local cache immediately so re-render is instant var c = allContractsView.find(function(x){ return x.id===contractId; }); if (c) c.status = newStatus; renderContractsView(); } catch(e) { alert('Failed to update: '+e.message); // Revert checkbox on failure by re-rendering renderContractsView(); } } // ── BULK IMPORTER ───────────────────────────────────────── var BULK_CONTRACTS_2026 = [ {nameMatch:'9 Burnhamthorpe', contract_name:'TSCC 1839 – 9 Burnhamthorpe Cres – 2026 Maintenance', startup:420, sp:300, v:3, winter:420, signed:'2026-03-11', notes:'Ground Floor System. Startup $420, Seasonal $300/visit ×3 (Jun/Jul/Aug), Winterize $420. Net 60.'}, {nameMatch:'60 Southport', contract_name:'YCC 87 – 60 Southport St – 2026 Maintenance', startup:600, sp:450, v:3, winter:600, signed:'2026-03-11', notes:'Ground Floor System. Startup $600, Seasonal $450/visit ×3 (Jun/Jul/Aug), Winterize $600. Net 60.'}, {nameMatch:'Park Mansion', contract_name:'PCC-421 – 45 Kingsbridge Garden Circle – 2026 Maintenance', startup:600, sp:500, v:4, winter:600, signed:'2026-03-11', notes:'Full property. Startup $600, Seasonal $500/visit ×4 (Jun–Sep), Winterize $600. Net 60.'}, {nameMatch:'Pavane', contract_name:'60 Pavane Linkway – 2026 Maintenance', startup:600, sp:360, v:3, winter:600, signed:'2026-03-11', notes:'Ground Floor Systems. Startup $600, Seasonal $360/visit ×3 (Jun/Jul/Aug), Winterize $600. Net 60.'}, {nameMatch:'College Condos', contract_name:'College Condos – Shared Facilities – 2026 Maintenance', startup:900, sp:540, v:2, winter:900, signed:'2026-03-17', notes:'Ground Floor, Shared Terrace, Green Roof (1), Green Roof (2). Startup $900, Seasonal $540/visit ×2 (Jun/Aug), Winterize $900. Annual total incl. HST $3,254.40. Net 60.'}, {nameMatch:'Stockyards', contract_name:'Stockyards District Residences – TSCC 2955 – 2026 Maintenance', startup:720, sp:0, v:0, winter:720, signed:'2026-03-17', notes:'Ground Floor, 2nd Floor Green Roof, Rooftop Green Roof (1) & (2). Startup $720, Winterize $720. No seasonal adjustments. Annual total incl. HST $1,627.20. Net 60.'}, {nameMatch:'World Trade', contract_name:'MTCC 989 – 10 Queens Quay W Ground Floor – 2026 Maintenance', startup:810, sp:450, v:4, winter:810, signed:'2026-03-17', notes:'10 Queens Quay W – Ground Floor. Startup $810, Seasonal $450/visit ×4 (Jun–Sep), Winterize $810. Annual total incl. HST $3,864.60. Net 60.'}, {nameMatch:'World Trade', contract_name:'MTCC 989 – 10 Queens Quay W 3rd Floor Terrace – 2026 Maintenance', startup:420, sp:210, v:4, winter:420, signed:'2026-03-17', notes:'10 Queens Quay W – 3rd Floor Terrace North & South Systems. Startup $420, Seasonal $210/visit ×4 (Jun–Sep), Winterize $420. Annual total incl. HST $1,898.40. Net 60.'}, {nameMatch:'125 Western Battery', contract_name:'125 Western Battery – Multi-Year Service Agreement 2022–2026', startup:630, sp:450, v:2, winter:630, signed:'2022-04-01', overrideStatus:'active', start:'2022-04-01', end:'2026-04-01', notes:'4-year agreement April 1 2022 – April 1 2026. Startup $630, Seasonal $450/visit ×2 (Jun/Sep), Winterize $630. $2,160+HST/year. Net 60.'}, {nameMatch:'205 Manning', contract_name:'TSCC 2611 – 205 Manning Ave – 2026 Maintenance', startup:390, sp:180, v:4, winter:390, signed:'2026-03-17', notes:'Ground Floor. Startup $390, Seasonal $180/visit ×4 (Jun–Sep), Winterize $390. Annual total incl. HST $1,695.00. Net 60.'}, {nameMatch:'Amber', contract_name:'Amber Condos – 5050 Four Springs Ave – 2026 Maintenance', startup:900, sp:450, v:3, winter:900, signed:'2026-03-11', notes:'Ground Floor and Roof Terrace. Startup $900, Seasonal $450/visit ×3 (Jun/Jul/Aug), Winterize $900. Net 60.'}, {nameMatch:'Grand Residences', contract_name:'P.S.C.C. 954 – Parkside Village 4070 Confederation – 2026 Maintenance', startup:810, sp:540, v:3, winter:810, signed:'2026-03-11', notes:'Ground Floor and Terrace. Startup $810, Seasonal $540/visit ×3 (Jun/Jul/Aug), Winterize $810. Net 60.'}, {nameMatch:'Pier 27', contract_name:'Pier 27 – Shared Facilities + 15/29/39 QQE – 2026 Maintenance', startup:1900, sp:1650, v:3, winter:1900, signed:'2026-03-16', notes:'Multi-corp. Shared $800/720×3/$800. 15QQE $300/210×3/$300. 29QQE $400/360×3/$400. 39QQE $400/360×3/$400. Total $8,750 excl. HST. Net 60.'}, {nameMatch:'Scenic III', contract_name:'Scenic III – 160 Vanderhoof Ave – 2026 Maintenance', startup:810, sp:420, v:3, winter:810, signed:'2026-03-11', notes:'Ground Floor and Internal Terrace. Startup $810, Seasonal $420/visit ×3 (Jun/Jul/Aug), Winterize $810. Net 60.'}, {nameMatch:'Alexandra Park', contract_name:'TSCC 2559 – SQ at Alexandra Park, 38 Cameron – 2026 Maintenance', startup:810, sp:540, v:3, winter:810, signed:'2026-03-11', notes:'Ground Floor and Terrace. Startup $810, Seasonal $540/visit ×3 (Jun/Jul/Aug), Winterize $810. Net 60.'}, {nameMatch:'21 Overlea', contract_name:'MTCC 964 – 21 Overlea Blvd – 2026 Maintenance', startup:500, sp:420, v:4, winter:500, signed:'2026-03-17', notes:'Full property. Startup $500, Seasonal $420/visit ×4 (Jun–Sep), Winterize $500. Annual total incl. HST $3,028.40. Net 60.'}, {nameMatch:'Absolute', contract_name:'Absolute Condos – 90 Absolute Ave Terrace – 2026 Maintenance', startup:600, sp:540, v:4, winter:600, signed:'2026-03-17', notes:'Terrace System. Startup $600, Seasonal $540/visit ×4 (Jun–Sep), Winterize $600. Annual total incl. HST $3,796.80. Net 60.'} ]; function findPropByName(nameMatch) { var q = nameMatch.toLowerCase(); return allProperties.find(function(p){ return (p.property_name||'').toLowerCase().indexOf(q) > -1; }); } function bcv(c) { if (!c.startup && !c.sp && !c.winter) return null; return (c.startup||0) + ((c.sp||0)*(c.v||0)) + (c.winter||0); } var bulkStatuses = {}; function renderBulkImporterTable() { var tbody = document.getElementById('bulk-import-tbody'); if (!tbody) return; var total = 0; tbody.innerHTML = BULK_CONTRACTS_2026.map(function(c, i) { var cv = bcv(c); if(cv) total += cv; var prop = findPropByName(c.nameMatch); var propDisplay = prop ? ''+esc(prop.property_name)+'✓ matched' : ''+esc(c.nameMatch)+'⚠ no match'; var dupe = allContractsView.some(function(x){ return x.contract_name===c.contract_name; }); var st = bulkStatuses[i]; var dc = st==='ok'?'#1A7A45':st==='err'?'#C0392B':st==='skip'?'#D4820A':st==='importing'?'#185FA5':dupe?'#D4820A':!prop?'#C0392B':'#D4DEDD'; var dl = st==='ok'?'✓ Done':st==='err'?'✗ Error':st==='skip'?'⚠ Exists':st==='importing'?'⏳…':dupe?'⚠ Exists':!prop?'⚠ No match':'Pending'; return '' +''+propDisplay+'' +''+(c.startup?'$'+c.startup:'—')+'' +''+(c.sp?'$'+c.sp+' ×'+c.v:'—')+'' +''+(c.winter?'$'+c.winter:'—')+'' +''+(cv?'$'+cv.toLocaleString():'TBD')+'' +''+dl+'' +''; }).join(''); var tc = document.getElementById('bulk-total-cell'); if(tc) tc.textContent = '$'+total.toLocaleString('en-CA',{minimumFractionDigits:0,maximumFractionDigits:0}); } function openBulkImporter() { bulkStatuses = {}; renderBulkImporterTable(); document.getElementById('bulk-import-log').innerHTML = 'Ready — click Import All to push to Supabase.'; document.getElementById('bulk-import-summary').textContent = ''; var btn = document.getElementById('bulk-import-btn'); btn.disabled=false; btn.textContent='⬆ Import All to Supabase'; btn.style.background=''; document.getElementById('bulk-import-modal').classList.add('open'); } function bLog(type, msg) { var el = document.getElementById('bulk-import-log'); if (!el) return; if (el.children.length===1 && el.querySelector('span')) el.innerHTML=''; var d = document.createElement('div'); d.style.color = type==='ok'?'#1A7A45':type==='err'?'#C0392B':type==='warn'?'#D4820A':'#6B7D7A'; d.textContent = msg; el.appendChild(d); el.scrollTop=el.scrollHeight; } function bSetStatus(i, state, label) { var col = {ok:'#1A7A45',err:'#C0392B',skip:'#D4820A',importing:'#185FA5'}[state]||'#D4DEDD'; var dot = document.getElementById('bi-dot-'+i); var lbl = document.getElementById('bi-lbl-'+i); if(dot) dot.style.background = col; if(lbl) lbl.innerHTML = ' '+label; } async function runBulkImport() { var btn = document.getElementById('bulk-import-btn'); btn.disabled=true; btn.textContent='⏳ Importing…'; document.getElementById('bulk-import-log').innerHTML=''; bLog('info','── Importing '+BULK_CONTRACTS_2026.length+' contracts + pipeline entries ──'); var ok=0, skipped=0, err=0; for (var i=0; i0) bLog('info','Go to Sales Pipeline → Approved Maintenance Contract to approve each one.'); document.getElementById('bulk-import-summary').textContent = ok+' imported · '+skipped+' skipped · '+err+' errors'; btn.disabled=false; btn.textContent = err>0 ? '↺ Retry ('+err+' failed)' : '✓ Complete'; if(err===0) btn.style.background='#1A7A45'; await loadContractsView(); loadProposals(); } // ── PROPOSAL IMPORTER ───────────────────────────────────── var piRows = []; // staged rows waiting to import var PI_VALID_TYPES = ['2026 Contract Sent','Approved Maintenance Contract','Repair Estimate Sent','Smart Irrigation Proposal Sent']; var PI_VALID_STATUSES = ['sent','follow_up_needed','approved','declined','drafting','expired']; function openProposalImporter() { piRows = []; document.getElementById('pi-csv').value = ''; document.getElementById('pi-log').innerHTML = ''; document.getElementById('pi-log-wrap').style.display = 'none'; document.getElementById('pi-result').textContent = ''; var btn = document.getElementById('pi-save-btn'); btn.disabled = false; btn.textContent = '⬆ Save All to Supabase'; btn.style.background = ''; // Populate property dropdown var propSel = document.getElementById('pi-m-prop'); propSel.innerHTML = allProperties .slice().sort(function(a,b){ return (a.property_name||'').localeCompare(b.property_name||''); }) .map(function(p){ return ''; }).join(''); piRenderPreview(); document.getElementById('proposal-importer-modal').classList.add('open'); } // Find a property by name fuzzy match function piFindProp(nameStr) { if (!nameStr) return null; var q = nameStr.trim().toLowerCase(); // exact match first var exact = allProperties.find(function(p){ return (p.property_name||'').toLowerCase() === q; }); if (exact) return exact; // substring match return allProperties.find(function(p){ return (p.property_name||'').toLowerCase().indexOf(q) > -1; }); } // Parse CSV text into piRows function piParse() { var raw = document.getElementById('pi-csv').value.trim(); if (!raw) { alert('Paste some CSV data first.'); return; } var lines = raw.split('\n').map(function(l){ return l.trim(); }).filter(Boolean); // Skip header row if first cell matches a column name if (lines[0].toLowerCase().startsWith('property')) lines.shift(); var added = 0; lines.forEach(function(line) { // Simple CSV parse — handles quoted fields var cols = piParseCSVLine(line); var row = { propName: (cols[0]||'').trim(), type: (cols[1]||'').trim(), status: (cols[2]||'Sent').trim(), amount: parseFloat(cols[3])||null, dateSent: (cols[4]||'').trim()||null, followUp: (cols[5]||'').trim()||null, notes: (cols[6]||'').trim()||null, _status: 'pending' // import status }; if (row.propName || row.type) { piRows.push(row); added++; } }); piRenderPreview(); document.getElementById('pi-csv').value = ''; document.getElementById('pi-count').textContent = piRows.length + ' rows staged'; } function piParseCSVLine(line) { var cols = [], cur = '', inQ = false; for (var i=0; i✓ '+esc(prop.property_name)+'' : '⚠ no match'; var typeValid = PI_VALID_TYPES.indexOf(r.type) > -1; var statusValid = PI_VALID_STATUSES.indexOf(r.status) > -1; var rowStyle = r._status==='ok' ? 'background:#f0fdf4' : r._status==='err' ? 'background:#fff5f5' : ''; var importBadge = r._status==='ok' ? '' : r._status==='err' ? '' : ''; var typeColor = PI_TYPE_COLORS[r.type]||'#6B7D7A'; var statusColor = PI_STATUS_COLORS[r.status]||'#6B7D7A'; return '' +''+esc(r.propName||'—')+'' +''+esc(r.type||'—')+''+(typeValid?'':'')+'' +''+esc(r.status||'—')+''+(statusValid?'':'')+'' +''+(r.amount?'$'+r.amount.toLocaleString():'—')+'' +''+(r.dateSent?fmtDate(r.dateSent):'—')+'' +''+(r.followUp?fmtDate(r.followUp):'—')+'' +''+esc(r.notes||'')+'' +''+matchBadge+'' +'' +importBadge +(r._status!=='ok' ? '' : '') +'' +''; }).join(''); } function piLog(type, msg) { var wrap = document.getElementById('pi-log-wrap'); var el = document.getElementById('pi-log'); if (!wrap || !el) return; wrap.style.display = 'block'; var d = document.createElement('div'); d.style.color = type==='ok'?'#1A7A45':type==='err'?'#C0392B':type==='warn'?'#D4820A':'#6B7D7A'; d.textContent = msg; el.appendChild(d); el.scrollTop = el.scrollHeight; } async function piImport() { if (!piRows.length) { alert('No rows to import. Add rows first.'); return; } var btn = document.getElementById('pi-save-btn'); btn.disabled = true; btn.textContent = '⏳ Saving…'; document.getElementById('pi-log').innerHTML = ''; document.getElementById('pi-log-wrap').style.display = 'block'; piLog('info', '── Saving ' + piRows.length + ' proposals to Supabase ──'); var ok=0, err=0, skipped=0; for (var i=0; i 0 ? '↺ Retry Errors' : '✓ Complete'; if (err===0) btn.style.background = '#1A7A45'; // Refresh pipeline view await loadProposals(); } // ── INIT sbUrl = 'https://bqimtyqkigewyrwyhjck.supabase.co'; sbKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJxaW10eXFraWdld3lyd3loamNrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzM3MTU4MzYsImV4cCI6MjA4OTI5MTgzNn0.L2A_rQCZLlbVsHe1s2Id182-7--FXMj5GkWEYRfNegI'; document.getElementById('config-banner').classList.add('hidden'); loadFromSupabase().then(function(){ startFMSync(); });