GottaGuyOS
Notifications
🔔
Loading…
← Home
💰 Collected This Month
$0
0 payments
⏳ Outstanding Balance
$0
across active jobs
📋 Jobs This Month
0
0 total
📊 Avg Job Value
$0
paid jobs
👥 Customers
0
total
⭐ Reviews
0
collected
📅 My Schedule — Upcoming Jobs ▲ hide
Loading…
0
All
0
Quoted
0
Scheduled
0
In Progress
0
Done/Paid
Job Customer Service Status Amount / Paid Date Actions
Customer Phone Email Jobs Revenue Actions
📬 Total Leads
🔴 New (uncontacted)
📞 Contacted
✅ Scheduled / Won
Date Name Phone Service Location Status Follow-up Actions
Loading leads…
📥 Pending Review
0 total
💬 Quoted
sent to customers
✅ Booked
converted to jobs
💰 Est. Profit Pipeline
$—
Gold tier, pending+quoted
Loading scans…
📅 Upcoming
Pending + confirmed
✅ Confirmed
Ready to go
🔔 Pending
Awaiting confirmation
📊 Total
All time
📋 Total Lists
All punch lists
🟡 Draft
In preparation
🟢 Active
Shared / in progress
✅ Completed
All items done
+(j.final_amount||j.quoted_amount||'TBD')); var tasksUrl='https://tasks.google.com/?pli=1#add&title='+tasksTitle+'&details='+tasksDetails; out+=''; if(j.address){var mapsUrl='https://www.google.com/maps/dir/?api=1&destination='+loc;out+='';} if(cust&&cust.email){var subj=encodeURIComponent('GottaGuy Home Services - '+(j.service_type||'')+' Update');var body=encodeURIComponent('Hi '+(cust.name||'there')+',\n\nJob: '+(j.title||'')+'\nService: '+(j.service_type||'')+'\nStatus: '+(j.status||'')+'\n\nJob Reference: #'+j.id+'\n\nThank you!\nGottaGuy Home Services LLC');var emailUrl='https://mail.google.com/mail/?view=cm&fs=1&to='+encodeURIComponent(cust.email)+'&su='+subj+'&body='+body;out+='';} return out; })() + '' + ((j.status === 'completed' || j.status === 'paid') ? (function(){ var rrSent = j.review_request_stage && j.review_request_stage >= 1; var rrLabel = rrSent ? '✉️' : '⭐'; var rrTitle = rrSent ? 'Review request sent' : 'Request a review'; var rrStyle = rrSent ? 'background:rgba(34,197,94,0.12);color:#16a34a;cursor:default;opacity:0.75;' : 'background:rgba(245,158,11,0.12);color:#b45309;'; var rrClick = rrSent ? 'event.stopPropagation()' : 'requestReview(' + j.id + ')'; return ''; })() : '') + (function(){ // PDF buttons: Estimate for quoted/scheduled, Invoice for completed/paid var pdfBtns = ''; var isCompleted = j.status === 'completed' || j.status === 'paid'; // Estimate button (always available) pdfBtns += ''; // Invoice button (shown when job has an amount or is completed) if (isCompleted || j.quoted_amount || j.final_amount) { pdfBtns += ''; // Email invoice button (only if customer has email) if (j.customer_email) { pdfBtns += ''; } } return pdfBtns; })() + '' + '' + '' + ''; }).join(''); } // ──────────────────────────────────────────────────────────── // PDF: Download invoice or estimate // ──────────────────────────────────────────────────────────── function downloadPDF(jobId, type) { var a = document.createElement('a'); a.href = '/api/jobs/' + jobId + '/' + type; a.download = 'GottaGuy-' + (type === 'invoice' ? 'Invoice' : 'Estimate') + '-' + (type === 'invoice' ? 'INV' : 'EST') + '-' + String(jobId).padStart(4, '0') + '.pdf'; document.body.appendChild(a); a.click(); document.body.removeChild(a); toast('Generating ' + (type === 'invoice' ? 'invoice' : 'estimate') + ' PDF…'); } // PDF: Email invoice or estimate to customer async function emailPDF(jobId, type, customerName) { if (!confirm('Email ' + (type === 'invoice' ? 'invoice' : 'estimate') + ' to ' + customerName + '?')) return; try { const result = await api('/api/jobs/' + jobId + '/email-invoice', { method: 'POST', body: { type } }); toast('✓ ' + (type === 'invoice' ? 'Invoice' : 'Estimate') + ' emailed!', 'success'); } catch (err) { toast('Failed to send email: ' + (err.message || 'unknown error'), 'error'); } } // ════════════════════════════════════════════════════════════ // MY SCHEDULE — upcoming jobs timeline with calendar links // ════════════════════════════════════════════════════════════ let scheduleVisible = true; function toggleSchedule() { scheduleVisible = !scheduleVisible; const body = document.getElementById('schedule-body'); const icon = document.getElementById('schedule-toggle-icon'); if (body) body.style.display = scheduleVisible ? '' : 'none'; if (icon) icon.textContent = scheduleVisible ? '▲ hide' : '▼ show'; try { localStorage.setItem('gg_schedule_visible', scheduleVisible ? '1' : '0'); } catch(e) {} } function renderSchedule() { const el = document.getElementById('schedule-list'); if (!el) return; // Restore collapse state const saved = (() => { try { return localStorage.getItem('gg_schedule_visible'); } catch(e) { return null; } })(); if (saved !== null) { scheduleVisible = saved !== '0'; const body = document.getElementById('schedule-body'); const icon = document.getElementById('schedule-toggle-icon'); if (body) body.style.display = scheduleVisible ? '' : 'none'; if (icon) icon.textContent = scheduleVisible ? '▲ hide' : '▼ show'; } const now = new Date(); now.setHours(0, 0, 0, 0); const upcoming = (jobs || []).filter(function(j) { if (!j.scheduled_date) return false; if (j.status === 'completed' || j.status === 'paid' || j.status === 'quoted') return false; const d = new Date(j.scheduled_date); d.setHours(0, 0, 0, 0); return d >= now; }).sort(function(a, b) { return new Date(a.scheduled_date) - new Date(b.scheduled_date); }).slice(0, 12); if (upcoming.length === 0) { el.innerHTML = '
No upcoming scheduled jobs.
'; return; } el.innerHTML = upcoming.map(function(j) { var d = new Date(j.scheduled_date + 'T12:00:00Z'); // noon UTC avoids timezone day shift var mon = d.toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' }); var dayNum = d.getUTCDate(); var dow = d.toLocaleDateString('en-US', { weekday: 'short', timeZone: 'UTC' }); var todayDate = new Date(); var isToday = d.getUTCFullYear() === todayDate.getFullYear() && d.getUTCMonth() === todayDate.getMonth() && d.getUTCDate() === todayDate.getDate(); var yy = d.getUTCFullYear(); var mm = ('0' + (d.getUTCMonth() + 1)).slice(-2); var dd2 = ('0' + d.getUTCDate()).slice(-2); var calDate = yy + mm + dd2 + 'T090000Z/' + yy + mm + dd2 + 'T110000Z'; var calTitle = encodeURIComponent((j.service_type || 'Job') + ' — ' + (j.customer_name || j.title || 'GottaGuy')); var calDetails = encodeURIComponent( 'Job: ' + (j.title || '') + ' | Customer: ' + (j.customer_name || '') + ' | Service: ' + (j.service_type || '') + ' | Status: ' + (j.status || '') + (j.notes ? ' | Notes: ' + j.notes : '') + ' | Amount: $' + (j.final_amount || j.quoted_amount || 'TBD') + '\n\nManage: https://gottaguy-home-services-llc.polsia.app/dashboard' ); var loc = encodeURIComponent(j.address || ''); var calUrl = 'https://calendar.google.com/calendar/render?action=TEMPLATE&text=' + calTitle + '&dates=' + calDate + '&details=' + calDetails + '&location=' + loc; var mapsUrl = j.address ? 'https://www.google.com/maps/dir/?api=1&destination=' + loc : ''; var tasksTitle = encodeURIComponent((j.service_type || 'Job') + ' \u2014 ' + (j.customer_name || j.title || 'GottaGuy')); var tasksDetails = encodeURIComponent('Job: ' + (j.title || '') + '\nCustomer: ' + (j.customer_name || '') + '\nAddress: ' + (j.address || 'TBD') + '\nService: ' + (j.service_type || '') + '\nStatus: ' + (j.status || '') + '\nNotes: ' + (j.notes || '') + '\nAmount: $' + (j.final_amount || j.quoted_amount || 'TBD')); var tasksUrl = 'https://tasks.google.com/?pli=1#add&title=' + tasksTitle + '&details=' + tasksDetails; var addrShort = j.address ? (j.address.length > 35 ? j.address.substring(0, 35) + '…' : j.address) : ''; var addrHtml = j.address ? ' · 📍 ' + escHtml(addrShort) + '' : ''; var subText = [ j.customer_name ? escHtml(j.customer_name) : '', j.service_type ? escHtml(j.service_type) : '' ].filter(Boolean).join(' · ') + addrHtml; return '
' + '
' + '
' + (isToday ? 'TODAY' : dow) + '
' + '
' + dayNum + '
' + '
' + mon + '
' + '
' + '
' + '
' + escHtml(j.title || j.service_type || 'Job') + '
' + '
' + subText + '
' + '
' + '
' + statusBadge(j.status) + '' + '' + '' + '
' + '
'; }).join(''); } // ════════════════════════════════════════════════════════════ // CALENDAR VIEW // ════════════════════════════════════════════════════════════ let calWeekStart = calGetMonday(new Date()); function calGetMonday(date) { const d = new Date(date); const day = d.getDay(); // 0=Sun const diff = (day === 0 ? -6 : 1 - day); d.setDate(d.getDate() + diff); d.setHours(0, 0, 0, 0); return d; } function setJobsView(mode) { const listCard = document.getElementById('jobs-list-card'); const calEl = document.getElementById('jobs-calendar'); const listBtn = document.getElementById('list-view-btn'); const calBtn = document.getElementById('cal-view-btn'); if (mode === 'calendar') { listCard.style.display = 'none'; calEl.style.display = ''; calEl.style.opacity = '0'; calEl.style.transform = 'translateY(8px)'; listBtn.classList.remove('active'); calBtn.classList.add('active'); renderCalendar(); requestAnimationFrame(function() { calEl.style.transition = 'opacity 0.22s cubic-bezier(0.4,0,0.2,1), transform 0.22s cubic-bezier(0.4,0,0.2,1)'; calEl.style.opacity = '1'; calEl.style.transform = 'translateY(0)'; }); } else { calEl.style.display = 'none'; listCard.style.display = ''; listCard.style.opacity = '0'; listCard.style.transform = 'translateY(8px)'; listBtn.classList.add('active'); calBtn.classList.remove('active'); requestAnimationFrame(function() { listCard.style.transition = 'opacity 0.22s cubic-bezier(0.4,0,0.2,1), transform 0.22s cubic-bezier(0.4,0,0.2,1)'; listCard.style.opacity = '1'; listCard.style.transform = 'translateY(0)'; }); } try { localStorage.setItem('gg_jobs_view', mode); } catch(e) {} } function calPrevWeek() { calWeekStart = new Date(calWeekStart); calWeekStart.setDate(calWeekStart.getDate() - 7); renderCalendar(); } function calNextWeek() { calWeekStart = new Date(calWeekStart); calWeekStart.setDate(calWeekStart.getDate() + 7); renderCalendar(); } function calGoToday() { calWeekStart = calGetMonday(new Date()); renderCalendar(); } function renderCalendar() { const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; const today = new Date(); today.setHours(0, 0, 0, 0); // Week label const weekEnd = new Date(calWeekStart); weekEnd.setDate(weekEnd.getDate() + 6); const fmt = d => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const lblYear = calWeekStart.getFullYear() !== today.getFullYear() ? ' ' + calWeekStart.getFullYear() : ''; document.getElementById('cal-week-label').textContent = fmt(calWeekStart) + lblYear + ' – ' + fmt(weekEnd); // Index jobs by scheduled_date (YYYY-MM-DD) const jobsByDate = {}; jobs.forEach(function(j) { if (!j.scheduled_date) return; const dk = j.scheduled_date.substring(0, 10); if (!jobsByDate[dk]) jobsByDate[dk] = []; jobsByDate[dk].push(j); }); const grid = document.getElementById('cal-grid'); grid.innerHTML = ''; for (let i = 0; i < 7; i++) { const day = new Date(calWeekStart); day.setDate(day.getDate() + i); const isToday = day.getTime() === today.getTime(); const mm = String(day.getMonth() + 1).padStart(2, '0'); const dd = String(day.getDate()).padStart(2, '0'); const dateKey = day.getFullYear() + '-' + mm + '-' + dd; const dayJobs = jobsByDate[dateKey] || []; const cell = document.createElement('div'); cell.className = 'cal-day' + (isToday ? ' cal-today' : ''); cell.title = 'Add job on ' + day.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }); (function(dk) { cell.onclick = function(e) { if (e.target.closest('.cal-job-card')) return; openJobModal(null, dk); }; })(dateKey); // Header: day name + date number const hdr = document.createElement('div'); hdr.className = 'cal-day-header'; const numSpan = document.createElement('span'); numSpan.className = 'cal-day-num'; numSpan.textContent = day.getDate(); hdr.innerHTML = '' + dayNames[i] + ''; hdr.appendChild(numSpan); cell.appendChild(hdr); // Body: job cards or add hint const body = document.createElement('div'); body.className = 'cal-day-body'; if (dayJobs.length === 0) { const hint = document.createElement('div'); hint.className = 'cal-add-hint'; hint.textContent = '+'; body.appendChild(hint); } else { dayJobs.forEach(function(j) { const card = document.createElement('div'); const statusClass = 'cal-job-' + j.status.replace('in-progress', 'in-progress'); card.className = 'cal-job-card ' + statusClass; const addr = j.address ? (j.address.length > 22 ? j.address.substring(0, 22) + '…' : j.address) : ''; card.innerHTML = '
' + escHtml(j.customer_name || j.title) + '
' + '
' + escHtml(j.service_type || '') + '
' + (addr ? '
' + escHtml(addr) + '
' : ''); (function(jid) { card.onclick = function(e) { e.stopPropagation(); editJob(jid); }; })(j.id); body.appendChild(card); }); } cell.appendChild(body); grid.appendChild(cell); } } function initJobsView() { let saved = 'list'; try { saved = localStorage.getItem('gg_jobs_view') || 'list'; } catch(e) {} setJobsView(saved); } // ════════════════════════════════════════════════════════════ // RENDER: CUSTOMERS TABLE // ════════════════════════════════════════════════════════════ function renderCustomers() { const tbody = document.getElementById('customers-table-body'); const empty = document.getElementById('customers-empty'); if (customers.length === 0) { tbody.innerHTML = ''; empty.style.display = 'block'; return; } empty.style.display = 'none'; tbody.innerHTML = customers.map(c => { return '' + '' + escHtml(c.name) + '' + (c.address ? '
' + escHtml(c.address) + '' : '') + '' + '' + (c.phone ? '' + escHtml(c.phone) + '' : '—') + '' + '' + (c.email ? escHtml(c.email) : '—') + '' + '' + (c.job_count || 0) + '' + '' + formatMoney(c.total_revenue) + '' + '' + '' + '' + '' + ''; }).join(''); } // ════════════════════════════════════════════════════════════ // JOB MODAL // ════════════════════════════════════════════════════════════ function openJobModal(job, prefillDate) { document.getElementById('job-modal-title').textContent = job ? 'Edit Job' : 'New Job'; document.getElementById('job-submit-btn').textContent = job ? 'Save Changes' : 'Create Job'; document.getElementById('job-id').value = job ? job.id : ''; document.getElementById('job-customer').value = job ? job.customer_id : ''; document.getElementById('job-title').value = job ? job.title : ''; document.getElementById('job-service-type').value = job ? job.service_type : ''; document.getElementById('job-status').value = job ? job.status : 'quoted'; document.getElementById('job-address').value = job ? (job.address || '') : ''; document.getElementById('job-quoted-amount').value = job ? (job.quoted_amount || '') : ''; document.getElementById('job-final-amount').value = job ? (job.final_amount || '') : ''; document.getElementById('job-scheduled-date').value = job ? (job.scheduled_date ? job.scheduled_date.split('T')[0] : '') : (prefillDate || ''); document.getElementById('job-notes').value = job ? (job.notes || '') : ''; document.getElementById('job-modal').classList.add('active'); } function closeJobModal() { document.getElementById('job-modal').classList.remove('active'); } async function saveJob(e) { e.preventDefault(); const id = document.getElementById('job-id').value; const body = { customer_id: parseInt(document.getElementById('job-customer').value), title: document.getElementById('job-title').value, service_type: document.getElementById('job-service-type').value, status: document.getElementById('job-status').value, address: document.getElementById('job-address').value, quoted_amount: parseFloat(document.getElementById('job-quoted-amount').value) || null, final_amount: parseFloat(document.getElementById('job-final-amount').value) || null, scheduled_date: document.getElementById('job-scheduled-date').value || null, notes: document.getElementById('job-notes').value }; try { if (id) { await api('/api/jobs/' + id, { method: 'PATCH', body }); toast('Job updated'); } else { await api('/api/jobs', { method: 'POST', body }); toast('Job created'); } closeJobModal(); loadAll(); } catch (err) { toast(err.message, 'error'); } } function editJob(id) { const job = jobs.find(j => j.id === id); if (job) openJobModal(job); } async function deleteJob(id) { if (!confirm('Delete this job? This cannot be undone.')) return; try { await api('/api/jobs/' + id, { method: 'DELETE' }); toast('Job deleted'); loadAll(); } catch (err) { toast(err.message, 'error'); } } async function requestReview(jobId) { const job = jobs.find(j => j.id === jobId); if (!job) return; const customerName = job.customer_name || 'this customer'; if (!confirm('Send a review request email to ' + customerName + '?')) return; try { await api('/api/reviews/request', { method: 'POST', body: { job_id: jobId } }); toast('✅ Review request sent to ' + customerName); loadAll(); } catch (err) { toast(err.message || 'Failed to send review request', 'error'); } } // ════════════════════════════════════════════════════════════ // STATUS MODAL // ════════════════════════════════════════════════════════════ const statusFlow = ['quoted', 'scheduled', 'in-progress', 'completed', 'paid']; const statusLabels = { quoted: 'Quoted', scheduled: 'Scheduled', 'in-progress': 'In Progress', completed: 'Completed', paid: 'Paid' }; function openStatusModal(id) { const job = jobs.find(j => j.id === id); if (!job) return; document.getElementById('status-job-id').value = id; const opts = document.getElementById('status-options'); opts.innerHTML = statusFlow.map(s => { const isCurrent = s === job.status; return ''; }).join(''); document.getElementById('status-modal').classList.add('active'); } function closeStatusModal() { document.getElementById('status-modal').classList.remove('active'); } async function updateJobStatus(id, status) { try { const body = { status }; // Auto-set final_amount from quoted if marking paid and no final set if (status === 'paid') { const job = jobs.find(j => j.id === id); if (job && !job.final_amount && job.quoted_amount) { body.final_amount = job.quoted_amount; } } await api('/api/jobs/' + id, { method: 'PATCH', body }); toast('Status updated to ' + statusLabels[status]); closeStatusModal(); loadAll(); } catch (err) { toast(err.message, 'error'); } } // ════════════════════════════════════════════════════════════ // CUSTOMER MODAL // ════════════════════════════════════════════════════════════ function openCustomerModal(customer) { document.getElementById('customer-modal-title').textContent = customer ? 'Edit Customer' : 'New Customer'; document.getElementById('customer-submit-btn').textContent = customer ? 'Save Changes' : 'Add Customer'; document.getElementById('customer-id').value = customer ? customer.id : ''; document.getElementById('customer-name').value = customer ? customer.name : ''; document.getElementById('customer-phone').value = customer ? (customer.phone || '') : ''; document.getElementById('customer-email').value = customer ? (customer.email || '') : ''; document.getElementById('customer-address').value = customer ? (customer.address || '') : ''; document.getElementById('customer-notes').value = customer ? (customer.notes || '') : ''; document.getElementById('customer-modal').classList.add('active'); } function closeCustomerModal() { document.getElementById('customer-modal').classList.remove('active'); } async function saveCustomer(e) { e.preventDefault(); const id = document.getElementById('customer-id').value; const body = { name: document.getElementById('customer-name').value, phone: document.getElementById('customer-phone').value, email: document.getElementById('customer-email').value, address: document.getElementById('customer-address').value, notes: document.getElementById('customer-notes').value }; try { if (id) { await api('/api/customers/' + id, { method: 'PATCH', body }); toast('Customer updated'); } else { await api('/api/customers', { method: 'POST', body }); toast('Customer added'); } closeCustomerModal(); loadAll(); } catch (err) { toast(err.message, 'error'); } } function editCustomer(id) { const customer = customers.find(c => c.id === id); if (customer) openCustomerModal(customer); } async function deleteCustomer(id) { const c = customers.find(c => c.id === id); if (!confirm('Delete ' + (c ? c.name : 'this customer') + ' and all their jobs? This cannot be undone.')) return; try { await api('/api/customers/' + id, { method: 'DELETE' }); toast('Customer deleted'); loadAll(); } catch (err) { toast(err.message, 'error'); } } function switchToNewCustomer(e) { e.preventDefault(); closeJobModal(); openCustomerModal(); } // ════════════════════════════════════════════════════════════ // DROPDOWNS // ════════════════════════════════════════════════════════════ function populateCustomerDropdown() { const sel = document.getElementById('job-customer'); const currentVal = sel.value; sel.innerHTML = '' + customers.map(c => '').join(''); sel.value = currentVal; } function populateServiceTypeDropdown() { const sel = document.getElementById('job-service-type'); const currentVal = sel.value; sel.innerHTML = '' + serviceTypes.map(t => '').join(''); sel.value = currentVal; } // ════════════════════════════════════════════════════════════ // PAYMENT MODAL // ════════════════════════════════════════════════════════════ let currentPaymentJobId = null; async function openPaymentModal(jobId) { currentPaymentJobId = jobId; const job = jobs.find(j => j.id === jobId); if (!job) return; document.getElementById('payment-modal-title').textContent = 'Record Payment — ' + escHtml(job.title); document.getElementById('payment-job-id').value = jobId; document.getElementById('payment-amount').value = ''; document.getElementById('payment-date').value = new Date().toISOString().split('T')[0]; document.getElementById('payment-method').value = 'Cash'; document.getElementById('payment-notes').value = ''; // Fetch current payment summary try { const data = await api('/api/jobs/' + jobId + '/payments'); updatePaymentSummary('ps', data); } catch (e) { // Non-fatal — show partial info from job const effectiveAmount = parseFloat(job.final_amount || job.quoted_amount || 0); const paid = parseFloat(job.total_paid || 0); updatePaymentSummaryRaw('ps', effectiveAmount, paid); } document.getElementById('payment-modal').classList.add('active'); } function closePaymentModal() { document.getElementById('payment-modal').classList.remove('active'); } function updatePaymentSummary(prefix, data) { updatePaymentSummaryRaw(prefix, data.effective_amount, data.total_paid); } function updatePaymentSummaryRaw(prefix, effective, paid) { document.getElementById(prefix + '-total').textContent = effective > 0 ? formatMoney(effective) : '—'; document.getElementById(prefix + '-paid').textContent = formatMoney(paid); document.getElementById(prefix + '-remaining').textContent = effective > 0 ? formatMoney(Math.max(0, effective - paid)) : '—'; const pct = effective > 0 ? Math.min(100, Math.round((paid / effective) * 100)) : 0; document.getElementById(prefix + '-bar').style.width = pct + '%'; } async function savePayment(e) { e.preventDefault(); const jobId = document.getElementById('payment-job-id').value; const body = { amount: parseFloat(document.getElementById('payment-amount').value), payment_date: document.getElementById('payment-date').value, payment_method: document.getElementById('payment-method').value, notes: document.getElementById('payment-notes').value }; try { await api('/api/jobs/' + jobId + '/payments', { method: 'POST', body }); toast('Payment recorded ✓'); closePaymentModal(); loadAll(); } catch (err) { toast(err.message, 'error'); } } // ════════════════════════════════════════════════════════════ // PAYMENT HISTORY MODAL // ════════════════════════════════════════════════════════════ async function openPaymentHistoryModal(jobId) { jobId = parseInt(jobId); currentPaymentJobId = jobId; const job = jobs.find(j => j.id === jobId); closePaymentModal(); document.getElementById('payment-history-title').textContent = 'Payment History' + (job ? ' — ' + escHtml(job.title) : ''); document.getElementById('payment-history-list').innerHTML = '
Loading...
'; document.getElementById('payment-history-modal').classList.add('active'); try { const data = await api('/api/jobs/' + jobId + '/payments'); updatePaymentSummary('ph', data); if (data.payments.length === 0) { document.getElementById('payment-history-list').innerHTML = '
No payments recorded yet.
'; } else { document.getElementById('payment-history-list').innerHTML = ''; } } catch (err) { document.getElementById('payment-history-list').innerHTML = '
Failed to load payment history.
'; } } function closePaymentHistoryModal() { document.getElementById('payment-history-modal').classList.remove('active'); } function openPaymentModalFromHistory() { closePaymentHistoryModal(); if (currentPaymentJobId) openPaymentModal(currentPaymentJobId); } async function deletePayment(paymentId, jobId) { if (!confirm('Delete this payment? This cannot be undone.')) return; try { await api('/api/payments/' + paymentId, { method: 'DELETE' }); toast('Payment deleted'); loadAll(); openPaymentHistoryModal(jobId); } catch (err) { toast(err.message, 'error'); } } // ════════════════════════════════════════════════════════════ // PHOTOS MODAL // ════════════════════════════════════════════════════════════ let currentPhotosJobId = null; let selectedPhotoFile = null; let currentPhotoLabel = 'Before'; async function openPhotosModal(jobId) { currentPhotosJobId = jobId; const job = jobs.find(j => j.id === jobId); document.getElementById('photos-job-id').value = jobId; document.getElementById('photos-modal-title').textContent = 'Photos' + (job ? ' — ' + escHtml(job.title) : ''); document.getElementById('photos-modal').classList.add('active'); clearPhotoFile(); await loadPhotos(jobId); } function closePhotosModal() { document.getElementById('photos-modal').classList.remove('active'); clearPhotoFile(); } async function loadPhotos(jobId) { document.getElementById('photos-loading').style.display = 'block'; document.getElementById('photos-grid').style.display = 'none'; document.getElementById('photos-empty-msg').style.display = 'none'; try { const photos = await api('/api/jobs/' + jobId + '/photos'); renderPhotosGrid(photos); } catch (err) { document.getElementById('photos-loading').textContent = 'Failed to load photos.'; } } function renderPhotosGrid(photos) { document.getElementById('photos-loading').style.display = 'none'; const grid = document.getElementById('photos-grid'); if (photos.length === 0) { grid.style.display = 'none'; document.getElementById('photos-empty-msg').style.display = 'block'; return; } document.getElementById('photos-empty-msg').style.display = 'none'; grid.style.display = 'grid'; grid.innerHTML = photos.map(p => { const labelClass = p.label === 'Before' ? 'photo-label-before' : p.label === 'After' ? 'photo-label-after' : 'photo-label-inprogress'; return '
' + '' + escHtml(p.label) + '' + '
' + escHtml(p.label) + '
' + '' + '
'; }).join(''); } function selectPhotoLabel(label) { currentPhotoLabel = label; document.getElementById('photo-label').value = label; document.querySelectorAll('.label-chip').forEach(c => c.classList.remove('selected')); const chipId = 'chip-' + label; const chip = document.getElementById(chipId); if (chip) chip.classList.add('selected'); } function handlePhotoFileSelect(e) { const file = e.target.files[0]; if (!file) return; selectedPhotoFile = file; const reader = new FileReader(); reader.onload = (ev) => { document.getElementById('photo-preview-img').src = ev.target.result; }; reader.readAsDataURL(file); document.getElementById('photo-preview-name').textContent = file.name; document.getElementById('photo-preview-size').textContent = (file.size / 1024 / 1024).toFixed(1) + ' MB'; document.getElementById('photo-preview-wrap').style.display = 'block'; } function clearPhotoFile() { selectedPhotoFile = null; document.getElementById('photo-file-input').value = ''; document.getElementById('photo-preview-wrap').style.display = 'none'; document.getElementById('photo-caption').value = ''; } async function uploadPhoto() { if (!selectedPhotoFile || !currentPhotosJobId) return; const btn = document.getElementById('photo-upload-btn'); btn.disabled = true; btn.textContent = 'Uploading...'; try { const formData = new FormData(); formData.append('photo', selectedPhotoFile); formData.append('label', document.getElementById('photo-label').value); formData.append('caption', document.getElementById('photo-caption').value); const res = await fetch('/api/jobs/' + currentPhotosJobId + '/photos', { method: 'POST', body: formData }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Upload failed'); toast('Photo uploaded ✓'); clearPhotoFile(); await loadPhotos(currentPhotosJobId); // Refresh job list to update photo count const jobsData = await api('/api/jobs'); jobs = jobsData; renderJobs(); } catch (err) { toast(err.message, 'error'); } finally { btn.disabled = false; btn.textContent = 'Upload Photo'; } } async function deletePhoto(photoId, e) { e.stopPropagation(); if (!confirm('Delete this photo?')) return; try { await api('/api/photos/' + photoId, { method: 'DELETE' }); toast('Photo deleted'); await loadPhotos(currentPhotosJobId); const jobsData = await api('/api/jobs'); jobs = jobsData; renderJobs(); } catch (err) { toast(err.message, 'error'); } } // ════════════════════════════════════════════════════════════ // LIGHTBOX // ════════════════════════════════════════════════════════════ function openLightbox(url, caption) { document.getElementById('lightbox-img').src = url; document.getElementById('lightbox-caption').textContent = caption || ''; document.getElementById('lightbox').classList.add('active'); } function closeLightbox() { document.getElementById('lightbox').classList.remove('active'); } document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeLightbox(); closePhotosModal(); closeRoomsModal(); } }); // ════════════════════════════════════════════════════════════ // ROOMS: localStorage helpers // ════════════════════════════════════════════════════════════ function getJobRooms(jobId) { try { return JSON.parse(localStorage.getItem('gg_rooms_' + jobId) || '[]'); } catch(e) { return []; } } function saveJobRooms(jobId, rooms) { localStorage.setItem('gg_rooms_' + jobId, JSON.stringify(rooms)); } function getRoomCount(jobId) { return getJobRooms(jobId).length; } // ── Measurement math ── function calcRoom(l, w, h) { l = parseFloat(l) || 0; w = parseFloat(w) || 0; h = parseFloat(h) || 8; const floor = parseFloat((l * w).toFixed(1)); const perim = parseFloat((2 * (l + w)).toFixed(1)); // Wall area: perimeter × height, minus 15% for doors/windows const wallRaw = perim * h; const wall = parseFloat((wallRaw * 0.85).toFixed(1)); const vol = parseFloat((l * w * h).toFixed(1)); return { floor, wall, perim, vol }; } // ── Totals across all rooms ── function sumRooms(rooms) { return rooms.reduce((acc, r) => { const c = calcRoom(r.length, r.width, r.height); acc.floor += c.floor; acc.wall += c.wall; acc.perim += c.perim; acc.vol += c.vol; return acc; }, { floor: 0, wall: 0, perim: 0, vol: 0 }); } // ════════════════════════════════════════════════════════════ // ROOMS: Material estimator // ════════════════════════════════════════════════════════════ const MATERIAL_CALCS = { painting: (totals, count) => [ { name: 'Paint — 2 coats (1 gal / 350 sqft)', qty: Math.ceil(totals.wall * 2 / 350) + ' gal' }, { name: 'Primer (1 gal / 400 sqft)', qty: Math.ceil(totals.wall / 400) + ' gal' }, { name: "Painter's Tape (60 ft rolls)", qty: Math.ceil(totals.perim * 0.35 / 60) + ' rolls' }, { name: 'Drop Cloths (1 per 200 sqft floor)', qty: Math.max(1, Math.ceil(totals.floor / 200)) + ' cloths' }, ], flooring: (totals, count) => [ { name: 'Flooring Material (+10% waste)', qty: Math.ceil(totals.floor * 1.10) + ' sqft' }, { name: 'Underlayment (+10% waste)', qty: Math.ceil(totals.floor * 1.10) + ' sqft' }, { name: 'Transition Strips (2 per room)', qty: (count * 2) + ' strips' }, ], tiling: (totals, count) => [ { name: 'Tile (+15% waste)', qty: Math.ceil(totals.floor * 1.15) + ' sqft' }, { name: 'Thinset Bags (1 per 50 sqft)', qty: Math.ceil(totals.floor / 50) + ' bags' }, { name: 'Grout Bags (1 per 25 sqft)', qty: Math.ceil(totals.floor / 25) + ' bags' }, { name: 'Spacer Packs (1 per 50 sqft)', qty: Math.max(1, Math.ceil(totals.floor / 50)) + ' packs' }, ], drywall: (totals, count) => [ { name: '4×8 Drywall Sheets (32 sqft ea)', qty: Math.ceil(totals.wall / 32) + ' sheets' }, { name: 'Joint Compound Buckets (1 per 200 sqft)', qty: Math.max(1, Math.ceil(totals.wall / 200)) + ' buckets' }, { name: 'Drywall Tape Rolls (1 per 300 sqft)', qty: Math.max(1, Math.ceil(totals.wall / 300)) + ' rolls' }, { name: 'Screw Boxes (1 per 500 sqft)', qty: Math.max(1, Math.ceil(totals.wall / 500)) + ' boxes' }, ], baseboard: (totals, count) => [ { name: 'Trim / Molding (+10% waste)', qty: Math.ceil(totals.perim * 1.10) + ' lf' }, { name: 'Finish Nail Boxes (1 per 100 lf)', qty: Math.max(1, Math.ceil(totals.perim / 100)) + ' boxes' }, { name: 'Caulk Tubes (1 per 50 lf)', qty: Math.max(1, Math.ceil(totals.perim / 50)) + ' tubes' }, ], }; function renderMaterialEstimate() { const jobId = document.getElementById('rooms-job-id').value; const rooms = getJobRooms(parseInt(jobId)); const jobType = document.getElementById('material-job-type').value; const wrap = document.getElementById('material-list-wrap'); const list = document.getElementById('material-list'); if (!jobType || rooms.length === 0) { wrap.style.display = 'none'; return; } const totals = sumRooms(rooms); const calcFn = MATERIAL_CALCS[jobType]; if (!calcFn) { wrap.style.display = 'none'; return; } const items = calcFn(totals, rooms.length); list.innerHTML = items.map(it => '
  • ' + '' + escHtml(it.name) + '' + '' + escHtml(it.qty) + '' + '
  • ' ).join(''); wrap.style.display = 'block'; } // ════════════════════════════════════════════════════════════ // ROOMS: Render helpers // ════════════════════════════════════════════════════════════ function renderRoomsList(rooms) { document.getElementById('rooms-loading').style.display = 'none'; const listEl = document.getElementById('rooms-list'); const emptyEl = document.getElementById('rooms-empty-msg'); const summaryEl = document.getElementById('rooms-summary-card'); const materialEl = document.getElementById('material-estimator'); if (rooms.length === 0) { listEl.innerHTML = ''; emptyEl.style.display = 'block'; summaryEl.style.display = 'none'; materialEl.style.display = 'none'; return; } emptyEl.style.display = 'none'; materialEl.style.display = 'block'; // Summary card const totals = sumRooms(rooms); summaryEl.style.display = 'block'; summaryEl.innerHTML = '
    ' + '
    📐 ' + rooms.length + ' Room' + (rooms.length !== 1 ? 's' : '') + ' — Combined Totals
    ' + '
    ' + '
    ' + '
    Floor
    ' + '
    ' + totals.floor.toFixed(0) + ' sqft
    ' + '
    ' + '
    ' + '
    Wall Area
    ' + '
    ' + totals.wall.toFixed(0) + ' sqft
    ' + '
    ' + '
    ' + '
    Perimeter
    ' + '
    ' + totals.perim.toFixed(0) + ' lf
    ' + '
    ' + '
    ' + '
    '; // Room cards listEl.innerHTML = rooms.map((r, idx) => { const c = calcRoom(r.length, r.width, r.height); return '
    ' + '
    ' + '
    📐 ' + escHtml(r.name || 'Room ' + (idx + 1)) + '
    ' + '' + '
    ' + '
    ' + (r.length || '?') + ' ft × ' + (r.width || '?') + ' ft × ' + (r.height || 8) + ' ft high
    ' + '
    ' + '
    ' + '
    Floor
    ' + '
    ' + c.floor + ' sqft
    ' + '
    ' + '
    ' + '
    Wall Area
    ' + '
    ' + c.wall + ' sqft
    ' + '
    ' + '
    ' + '
    Perimeter
    ' + '
    ' + c.perim + ' lf
    ' + '
    ' + '
    ' + '
    Volume
    ' + '
    ' + c.vol + ' cu ft
    ' + '
    ' + '
    ' + '
    '; }).join(''); // Re-render material estimate if job type is already selected renderMaterialEstimate(); } // ════════════════════════════════════════════════════════════ // ROOMS: Modal open/close // ════════════════════════════════════════════════════════════ let currentRoomsJobId = null; function openRoomsModal(jobId) { currentRoomsJobId = jobId; const job = jobs.find(j => j.id === jobId); document.getElementById('rooms-job-id').value = jobId; document.getElementById('rooms-modal-title').textContent = 'Room Measurements' + (job ? ' — ' + escHtml(job.title) : ''); document.getElementById('rooms-loading').style.display = 'block'; document.getElementById('rooms-list').innerHTML = ''; document.getElementById('rooms-modal').classList.add('active'); // Reset add form document.getElementById('room-name').value = ''; document.getElementById('room-length').value = ''; document.getElementById('room-width').value = ''; document.getElementById('room-height').value = '8'; document.getElementById('live-calcs-box').classList.remove('visible'); document.getElementById('material-job-type').value = ''; // Pre-select job type from job's service_type if it maps if (job) { const st = (job.service_type || '').toLowerCase(); const map = { painting: 'painting', flooring: 'flooring', tiling: 'tiling', drywall: 'drywall', baseboard: 'baseboard', 'crown molding': 'baseboard', 'tile': 'tiling' }; for (const k of Object.keys(map)) { if (st.includes(k)) { document.getElementById('material-job-type').value = map[k]; break; } } } const rooms = getJobRooms(jobId); renderRoomsList(rooms); } function closeRoomsModal() { document.getElementById('rooms-modal').classList.remove('active'); currentRoomsJobId = null; } // ════════════════════════════════════════════════════════════ // ROOMS: Live calc preview // ════════════════════════════════════════════════════════════ function updateLiveCalcs() { const l = document.getElementById('room-length').value; const w = document.getElementById('room-width').value; const h = document.getElementById('room-height').value; if (!l || !w) { document.getElementById('live-calcs-box').classList.remove('visible'); return; } const c = calcRoom(l, w, h || 8); document.getElementById('live-floor').textContent = c.floor + ' sqft'; document.getElementById('live-wall').textContent = c.wall + ' sqft'; document.getElementById('live-perim').textContent = c.perim + ' lf'; document.getElementById('live-vol').textContent = c.vol + ' cuft'; document.getElementById('live-calcs-box').classList.add('visible'); } // ════════════════════════════════════════════════════════════ // ROOMS: Add / Delete // ════════════════════════════════════════════════════════════ function addRoom() { const jobId = parseInt(document.getElementById('rooms-job-id').value); const name = document.getElementById('room-name').value.trim(); const length = parseFloat(document.getElementById('room-length').value); const width = parseFloat(document.getElementById('room-width').value); const height = parseFloat(document.getElementById('room-height').value) || 8; if (!name) { toast('Enter a room name', 'error'); document.getElementById('room-name').focus(); return; } if (!length || !width) { toast('Enter length and width', 'error'); return; } const rooms = getJobRooms(jobId); rooms.push({ name, length, width, height, created: new Date().toISOString() }); saveJobRooms(jobId, rooms); toast('Room saved ✓'); // Reset form document.getElementById('room-name').value = ''; document.getElementById('room-length').value = ''; document.getElementById('room-width').value = ''; document.getElementById('room-height').value = '8'; document.getElementById('live-calcs-box').classList.remove('visible'); renderRoomsList(rooms); // Refresh jobs table to update badge renderJobs(); } function deleteRoom(idx) { if (!confirm('Delete this room?')) return; const jobId = parseInt(document.getElementById('rooms-job-id').value); const rooms = getJobRooms(jobId); rooms.splice(idx, 1); saveJobRooms(jobId, rooms); toast('Room removed'); renderRoomsList(rooms); renderJobs(); } // ════════════════════════════════════════════════════════════ // EXPORT / IMPORT PANEL // ════════════════════════════════════════════════════════════ function openExportImportModal() { document.getElementById('export-import-modal').classList.add('active'); } function closeExportImportModal() { document.getElementById('export-import-modal').classList.remove('active'); // reset file input so same file can be re-selected var fi = document.getElementById('import-csv-file'); if (fi) fi.value = ''; } // ── Shared CSV helper ── var csvCell = function(v) { return '"' + String(v == null ? '' : v).replace(/"/g, '""') + '"'; }; function buildCsvBlob(headers, dataRows) { var rows = [headers.map(csvCell)].concat(dataRows.map(function(r){ return r.map(csvCell); })); return new Blob([rows.map(function(r){ return r.join(','); }).join('\n')], { type: 'text/csv;charset=utf-8;' }); } function downloadBlob(blob, filename) { var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function todayStr() { return new Date().toISOString().split('T')[0]; } // ── Export: Jobs ── function exportJobsCSV() { if (!jobs || jobs.length === 0) { toast('No jobs to export', 'error'); return; } var headers = ['ID','Customer Name','Phone','Email','Address','Service Type','Status','Quoted Amount','Final Amount','Total Paid','Scheduled Date','Notes','Created Date']; var rows = jobs.map(function(j) { return [ j.id, j.customer_name || '', j.customer_phone || '', j.customer_email || '', j.address || '', j.service_type || '', j.status || '', j.quoted_amount || '', j.final_amount || '', j.total_paid || '', j.scheduled_date ? j.scheduled_date.split('T')[0] : '', (j.notes || '').replace(/\n/g, ' '), j.created_at ? j.created_at.split('T')[0] : '' ]; }); downloadBlob(buildCsvBlob(headers, rows), 'gottaguy-jobs-' + todayStr() + '.csv'); toast('Jobs CSV downloaded ✓'); } // ── Export: Payments ── async function exportPaymentsCSV() { if (!jobs || jobs.length === 0) { toast('No jobs to export', 'error'); return; } var btn = document.getElementById('export-payments-btn'); if (btn) { btn.disabled = true; btn.textContent = '⏳ Loading…'; } try { var allRows = []; for (var i = 0; i < jobs.length; i++) { var j = jobs[i]; try { var data = await api('/api/jobs/' + j.id + '/payments'); var running = 0; var sorted = (data.payments || []).slice().sort(function(a,b){ return new Date(a.payment_date) - new Date(b.payment_date); }); sorted.forEach(function(p) { running += parseFloat(p.amount || 0); allRows.push([ j.id, j.title || '', j.customer_name || '', j.address || '', p.id, p.payment_date ? p.payment_date.split('T')[0] : '', parseFloat(p.amount || 0).toFixed(2), p.payment_method || '', (p.notes || '').replace(/\n/g, ' '), running.toFixed(2) ]); }); } catch(e) { /* skip this job */ } } if (allRows.length === 0) { toast('No payments recorded yet', 'error'); return; } var headers = ['Job ID','Job Title','Customer','Address','Payment ID','Date','Amount','Method','Notes','Running Total']; downloadBlob(buildCsvBlob(headers, allRows), 'gottaguy-payments-' + todayStr() + '.csv'); toast('Payments CSV downloaded ✓'); } finally { if (btn) { btn.disabled = false; btn.textContent = '💰 Payments'; } } } // ── Export: Rooms / Measurements ── function exportRoomsCSV() { var allRows = []; jobs.forEach(function(j) { var rooms = []; try { rooms = JSON.parse(localStorage.getItem('gg_rooms_' + j.id) || '[]'); } catch(e) {} rooms.forEach(function(r) { var l = parseFloat(r.length||0), w = parseFloat(r.width||0), h = parseFloat(r.height||8); var floorSqFt = (l * w).toFixed(1); var perimeter = (2*(l+w)).toFixed(1); var wallArea = ((parseFloat(perimeter)*h)*0.85).toFixed(1); var volume = (l*w*h).toFixed(1); allRows.push([ j.id, j.title || '', j.customer_name || '', j.address || '', j.service_type || '', r.name || '', r.length || '', r.width || '', r.height || 8, floorSqFt, wallArea, perimeter, volume, r.created ? r.created.split('T')[0] : '' ]); }); }); if (allRows.length === 0) { toast('No room measurements saved yet', 'error'); return; } var headers = ['Job ID','Job Title','Customer','Address','Service Type','Room Name','Length (ft)','Width (ft)','Height (ft)','Floor Sq Ft','Wall Area Sq Ft','Perimeter (linear ft)','Volume (cu ft)','Added Date']; downloadBlob(buildCsvBlob(headers, allRows), 'gottaguy-rooms-' + todayStr() + '.csv'); toast('Rooms CSV downloaded ✓'); } // ── Export: Customers ── function exportCustomersCSV() { if (!customers || customers.length === 0) { toast('No customers yet', 'error'); return; } var headers = ['ID','Name','Phone','Email','Address','Total Jobs','Total Revenue ($)','Notes','Added Date']; var rows = customers.map(function(c) { return [ c.id, c.name || '', c.phone || '', c.email || '', c.address || '', c.job_count || 0, parseFloat(c.total_revenue || 0).toFixed(2), (c.notes || '').replace(/\n/g, ' '), c.created_at ? c.created_at.split('T')[0] : '' ]; }); downloadBlob(buildCsvBlob(headers, rows), 'gottaguy-customers-' + todayStr() + '.csv'); toast('Customers CSV downloaded ✓'); } // ════════════════════════════════════════════════════════════ // CSV IMPORT // ════════════════════════════════════════════════════════════ var _importPending = []; function downloadImportTemplate() { var headers = ['Customer Name','Phone','Email','Address','Service Type','Status','Quoted Amount','Notes']; var example = ['Jane Smith','(555) 867-5309','jane@example.com','123 Main St, Anytown FL','Painting','quoted','1500','Paint living room and hallway']; downloadBlob(buildCsvBlob(headers, [example]), 'gottaguy-import-template.csv'); toast('Template downloaded ✓'); } function parseCSV(text) { var lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); var result = []; for (var i = 0; i < lines.length; i++) { var line = lines[i].trim(); if (!line) continue; var row = [], cur = '', inQ = false; for (var c = 0; c < line.length; c++) { var ch = line[c]; if (inQ) { if (ch === '"' && line[c+1] === '"') { cur += '"'; c++; } else if (ch === '"') { inQ = false; } else { cur += ch; } } else { if (ch === '"') { inQ = true; } else if (ch === ',') { row.push(cur.trim()); cur = ''; } else { cur += ch; } } } row.push(cur.trim()); result.push(row); } return result; } function handleImportFile(event) { var file = event.target.files[0]; if (!file) return; var reader = new FileReader(); reader.onload = function(e) { try { var rows = parseCSV(e.target.result); if (rows.length < 2) { toast('CSV is empty or has only headers', 'error'); return; } buildImportPreview(rows); } catch(err) { toast('Could not parse CSV file', 'error'); } }; reader.readAsText(file); } function normalizeHeader(h) { return h.toLowerCase().replace(/[^a-z0-9]/g, ''); } function buildImportPreview(rows) { var headerRow = rows[0].map(normalizeHeader); var dataRows = rows.slice(1); function col(row, names) { for (var i = 0; i < names.length; i++) { var idx = headerRow.indexOf(names[i]); if (idx !== -1 && idx < row.length) return (row[idx] || '').trim(); } return ''; } // Build a lookup of existing customer+address combos for dupe detection var existingKeys = {}; jobs.forEach(function(j) { var k = (j.customer_name || '').toLowerCase().trim() + '|' + (j.address || '').toLowerCase().trim(); existingKeys[k] = true; }); var parsed = dataRows.map(function(row, idx) { var name = col(row, ['customername','name','customer']); var phone = col(row, ['phone','phonenumber','tel']); var email = col(row, ['email','emailaddress']); var address = col(row, ['address','addr','location']); var serviceType = col(row, ['servicetype','service','type','jobtype']); var status = col(row, ['status']); var quotedAmount = col(row, ['quotedamount','amount','quoted','price','cost']); var notes = col(row, ['notes','note','description']); var issue = ''; var rowClass = 'valid'; if (!name) { issue = 'Missing customer name'; rowClass = 'invalid'; } else { var k = name.toLowerCase().trim() + '|' + address.toLowerCase().trim(); if (existingKeys[k]) { issue = 'Possible duplicate'; rowClass = 'duplicate'; } } return { idx: idx+1, name, phone, email, address, serviceType, status, quotedAmount, notes, issue, rowClass }; }); _importPending = parsed; var valid = parsed.filter(function(r){ return r.rowClass !== 'invalid'; }).length; var invalid = parsed.filter(function(r){ return r.rowClass === 'invalid'; }).length; var dupes = parsed.filter(function(r){ return r.rowClass === 'duplicate'; }).length; var summaryEl = document.getElementById('import-preview-summary'); summaryEl.innerHTML = '' + parsed.length + ' rows parsed. ' + '' + (valid - dupes) + ' valid' + (dupes ? ' · ' + dupes + ' with duplicate warning (will still import)' : '') + (invalid ? ' · ' + invalid + ' invalid (will be skipped)' : ''); var tbody = document.getElementById('import-preview-rows'); tbody.innerHTML = parsed.map(function(r) { var bg = r.rowClass === 'invalid' ? '#fee2e2' : r.rowClass === 'duplicate' ? '#fef9c3' : '#f0fdf4'; var borderColor = r.rowClass === 'invalid' ? '#fca5a5' : r.rowClass === 'duplicate' ? '#fde047' : '#86efac'; return '' + '' + r.idx + '' + '' + escHtml(r.name || '—') + '' + '' + escHtml(r.phone) + '' + '' + escHtml(r.address) + '' + '' + escHtml(r.serviceType) + '' + '' + escHtml(r.status || 'quoted') + '' + '' + (r.quotedAmount ? '$' + parseFloat(r.quotedAmount||0).toFixed(2) : '') + '' + '' + escHtml(r.issue) + '' + ''; }).join(''); var importBtn = document.getElementById('confirm-import-btn'); importBtn.textContent = 'Import ' + (valid) + ' Job' + (valid !== 1 ? 's' : ''); importBtn.disabled = valid === 0; document.getElementById('export-import-modal').classList.remove('active'); document.getElementById('import-preview-modal').classList.add('active'); } function closeImportPreviewModal() { document.getElementById('import-preview-modal').classList.remove('active'); var fi = document.getElementById('import-csv-file'); if (fi) fi.value = ''; } async function confirmImport() { var toImport = _importPending.filter(function(r){ return r.rowClass !== 'invalid'; }); if (!toImport.length) { toast('Nothing to import', 'error'); return; } var btn = document.getElementById('confirm-import-btn'); btn.disabled = true; btn.textContent = 'Importing…'; var succeeded = 0, failed = 0; for (var i = 0; i < toImport.length; i++) { var r = toImport[i]; try { // First: find or create customer var existingCustomer = customers.find(function(c){ return c.name.toLowerCase().trim() === r.name.toLowerCase().trim(); }); var customerId; if (existingCustomer) { customerId = existingCustomer.id; } else { var newCust = await api('/api/customers', { method: 'POST', body: { name: r.name, phone: r.phone || null, email: r.email || null, address: r.address || null } }); customerId = newCust.id; } // Create the job var validStatuses = ['quoted','scheduled','in-progress','paid','cancelled']; var jobStatus = validStatuses.includes((r.status||'').toLowerCase()) ? r.status.toLowerCase() : 'quoted'; var body = { customer_id: customerId, title: r.serviceType ? r.serviceType + ' – ' + r.name : r.name, service_type: r.serviceType || null, status: jobStatus, address: r.address || null, quoted_amount: r.quotedAmount ? parseFloat(r.quotedAmount) : null, notes: r.notes || null }; await api('/api/jobs', { method: 'POST', body: body }); succeeded++; } catch(e) { failed++; } } btn.disabled = false; closeImportPreviewModal(); await loadAll(); var msg = succeeded + ' job' + (succeeded !== 1 ? 's' : '') + ' imported ✓'; if (failed) msg += ' (' + failed + ' failed)'; toast(msg, failed ? 'error' : 'success'); _importPending = []; } // ════════════════════════════════════════════════════════════ // ROOM SCANS — PREMIUM CONTRACTOR VIEW // ════════════════════════════════════════════════════════════ let scans = []; let scansFilter = 'all'; let selectedScanId = null; async function loadScans() { document.getElementById('scans-loading').style.display = 'block'; document.getElementById('scans-empty').style.display = 'none'; document.getElementById('scans-list').innerHTML = ''; try { const [scanData, statsData] = await Promise.all([ api('/api/room-scans'), api('/api/room-scans/stats'), ]); scans = scanData; renderScansStats(statsData); renderScans(); } catch (err) { document.getElementById('scans-loading').style.display = 'none'; toast('Failed to load scans', 'error'); } } function renderScansStats(data) { const s = data.stats || {}; document.getElementById('scan-stat-pending').textContent = s.pending_count || 0; document.getElementById('scan-stat-total').textContent = (s.total || 0) + ' total'; document.getElementById('scan-stat-quoted').textContent = s.quoted_count || 0; document.getElementById('scan-stat-booked').textContent = s.booked_count || 0; // Badge const badge = document.getElementById('scans-badge'); const pending = parseInt(s.pending_count || 0); if (pending > 0) { badge.textContent = pending; badge.style.display = 'inline'; } else badge.style.display = 'none'; // Estimated profit pipeline: sum gold suggested_price - gold total_cost for pending/quoted scans const pipelineScans = scans.filter(sc => sc.status === 'pending' || sc.status === 'quoted'); let profit = 0; pipelineScans.forEach(sc => { const g = typeof sc.gold_estimate === 'string' ? JSON.parse(sc.gold_estimate) : sc.gold_estimate; if (g && g.profit_dollar) profit += g.profit_dollar; }); document.getElementById('scan-stat-profit').textContent = profit > 0 ? '$' + profit.toLocaleString() : '$0'; } function filterScans(filter) { scansFilter = filter; document.querySelectorAll('[id^="scan-tab-"]').forEach(btn => btn.classList.remove('active')); const tabEl = document.getElementById('scan-tab-' + filter); if (tabEl) tabEl.classList.add('active'); renderScans(); } function renderScans() { const loading = document.getElementById('scans-loading'); const empty = document.getElementById('scans-empty'); const list = document.getElementById('scans-list'); loading.style.display = 'none'; const filtered = scansFilter === 'all' ? scans : scans.filter(sc => sc.status === scansFilter); if (filtered.length === 0) { list.innerHTML = ''; empty.style.display = 'block'; return; } empty.style.display = 'none'; list.innerHTML = filtered.map(sc => renderScanCard(sc)).join(''); } function parseTier(raw) { if (!raw) return null; if (typeof raw === 'string') { try { return JSON.parse(raw); } catch(e) { return null; } } return raw; } function roiDot(color) { const colors = { green: '#22c55e', yellow: '#f59e0b', red: '#ef4444' }; return ''; } function tierBadge(tier) { const labels = { silver: '🥈 Silver', gold: '🥇 Gold', platinum: '💎 Platinum' }; return labels[tier] || tier; } function statusBadge(status) { const map = { pending: 'Pending', reviewed: 'Reviewed', quoted: 'Quoted', booked: 'Booked 🏆', dismissed:'Dismissed', }; return map[status] || status; } function renderScanCard(sc) { const silver = parseTier(sc.silver_estimate); const gold = parseTier(sc.gold_estimate); const platinum = parseTier(sc.platinum_estimate); const photos = typeof sc.photo_urls === 'string' ? JSON.parse(sc.photo_urls || '[]') : (sc.photo_urls || []); const ai = typeof sc.ai_analysis === 'string' ? JSON.parse(sc.ai_analysis || '{}') : (sc.ai_analysis || {}); const projectLabel = (sc.project_type || '').replace(/_/g, ' '); const bestTier = gold && platinum && gold.profit_dollar < platinum.profit_dollar ? platinum : gold; const bestTierName = bestTier === platinum ? 'platinum' : 'gold'; const confColor = ai.confidence_level === 'high' ? '#22c55e' : ai.confidence_level === 'medium' ? '#f59e0b' : '#ef4444'; return '
    ' + '
    ' + '
    ' + '
    ' + '
    ' + '

    ' + escHtml(sc.customer_name) + '

    ' + statusBadge(sc.status) + '
    ' + '
    ' + '📐 ' + escHtml(projectLabel.charAt(0).toUpperCase() + projectLabel.slice(1)) + (sc.service_area ? ' · ' + escHtml(sc.service_area) : '') + (ai.estimated_sqft ? ' · ~' + ai.estimated_sqft + ' sqft' : '') + ' · ' + (ai.confidence_level || 'low') + ' confidence' + '
    ' + '
    ' + (sc.customer_phone ? '📞 ' + escHtml(sc.customer_phone) + ' ' : '') + (sc.customer_email ? '✉️ ' + escHtml(sc.customer_email) : '') + '
    ' + '
    ' + '
    ' + (photos.length > 0 ? '📷 ' + photos.length + ' photo' + (photos.length !== 1 ? 's' : '') + '' : '') + '' + new Date(sc.created_at).toLocaleDateString() + '' + '
    ' + '
    ' + '
    ' + /* Tier comparison strip */ '
    ' + renderTierMiniCard(silver, 'silver', bestTierName) + renderTierMiniCard(gold, 'gold', bestTierName) + renderTierMiniCard(platinum, 'platinum', bestTierName) + '
    ' + /* Quick actions */ '
    ' + '' + '' + (sc.status !== 'reviewed' ? '' : '') + (sc.status !== 'booked' ? '' : '') + '' + '
    ' + '
    '; } function renderTierMiniCard(tier, tierName, bestTierName) { if (!tier) return '
    No estimate
    '; const isBest = tierName === bestTierName; const borderStyle = isBest ? 'border:2px solid var(--amber);' : 'border:1px solid var(--border);'; const bgStyle = isBest ? 'background:rgba(245,166,35,0.06);' : 'background:var(--bg-raised);'; return '
    ' + (isBest ? '
    🏆 Best Margin
    ' : '') + '
    ' + tierBadge(tierName) + '
    ' + '
    Cost: $' + tier.total_cost + '
    ' + '
    Charge: $' + tier.suggested_price + '
    ' + '
    ' + roiDot(tier.roi_color) + '$' + tier.profit_dollar + ' profit (' + tier.profit_pct + '%)
    ' + '
    '; } async function openScanDetail(id) { selectedScanId = id; const scan = scans.find(s => s.id === id); if (!scan) return; const silver = parseTier(scan.silver_estimate); const gold = parseTier(scan.gold_estimate); const platinum = parseTier(scan.platinum_estimate); const ai = typeof scan.ai_analysis === 'string' ? JSON.parse(scan.ai_analysis || '{}') : (scan.ai_analysis || {}); const photos = typeof scan.photo_urls === 'string' ? JSON.parse(scan.photo_urls || '[]') : (scan.photo_urls || []); const projectLabel = (scan.project_type || '').replace(/_/g, ' '); document.getElementById('scan-detail-title').textContent = scan.customer_name + ' — ' + projectLabel.charAt(0).toUpperCase() + projectLabel.slice(1); const renderFullTier = (tier, tierName) => { if (!tier) return ''; const materials = tier.materials || []; const tierColors = { silver: { bg: 'rgba(148,163,184,0.08)', border: 'rgba(148,163,184,0.2)', accent: '#94a3b8' }, gold: { bg: 'rgba(245,166,35,0.08)', border: 'rgba(245,166,35,0.25)', accent: '#f5a623' }, platinum:{ bg: 'rgba(99,102,241,0.08)', border: 'rgba(99,102,241,0.25)', accent: '#818cf8' }, }; const tc = tierColors[tierName] || tierColors.silver; const profitColor = tier.roi_color === 'green' ? '#4ade80' : tier.roi_color === 'yellow' ? '#f5a623' : '#f87171'; return '
    ' + '
    ' + tierBadge(tierName) + '
    ' + '
    ' + '
    Materials
    $' + tier.material_cost + '
    ' + '
    ' + '
    Labor
    ' + '
    ' + '$' + '' + '
    ' + '
    ' + '
    Total Cost
    $' + tier.total_cost + '
    ' + '
    Charge Customer
    $' + tier.suggested_price + '
    ' + '
    ' + '
    ' + '
    Profit
    ' + '
    ' + '$' + tier.profit_dollar + '' + '(' + tier.profit_pct + '%)' + '' + tier.markup_pct + '% markup' + '
    ' + '
    ' + '
    ' + '📋 Materials list (' + materials.length + ' items)' + '
    ' + materials.map(m => '
    ' + '' + escHtml(m.name) + ' × ' + m.quantity + ' ' + m.unit + '' + '$' + m.cost + '' + '
    ').join('') + '
    ' + '
    ' + '
    ' + '' + '' + '
    ' + '
    '; }; document.getElementById('scan-detail-body').innerHTML = '
    ' + /* Customer + AI summary */ '
    ' + '
    ' + '
    Customer
    ' + escHtml(scan.customer_name) + '
    ' + (scan.customer_phone ? '
    Phone
    ' + escHtml(scan.customer_phone) + '
    ' : '') + (scan.customer_email ? '
    Email
    ' + escHtml(scan.customer_email) + '
    ' : '') + (scan.service_area ? '
    Area
    ' + escHtml(scan.service_area) + '
    ' : '') + (ai.estimated_sqft ? '
    Est. Area
    ~' + ai.estimated_sqft + ' sqft
    ' : '') + (ai.estimated_length ? '
    Dimensions
    ' + ai.estimated_length + '×' + ai.estimated_width + '×' + (ai.estimated_height || '?') + ' ft
    ' : '') + '
    Confidence
    ' + (scan.confidence_level || 'low') + '
    ' + '
    ' + (scan.description ? '
    Customer description: ' + escHtml(scan.description) + '
    ' : '') + (ai.notes ? '
    AI notes: ' + escHtml(ai.notes) + '
    ' : '') + '
    ' + /* Photos */ (photos.length > 0 ? '
    Photos (' + photos.length + ')
    ' + photos.map(url => '').join('') + '
    ' : '') + /* 3-tier cards */ '
    ' + renderFullTier(silver, 'silver') + renderFullTier(gold, 'gold') + renderFullTier(platinum, 'platinum') + '
    ' + /* Owner notes */ '
    ' + '' + '' + '
    ' + '
    ' + '' + '' + '' + '' + '
    ' + '
    '; document.getElementById('scan-detail-overlay').style.display = 'flex'; document.getElementById('scan-detail-overlay').classList.add('active'); } function closeScanDetail() { document.getElementById('scan-detail-overlay').classList.remove('active'); setTimeout(() => { document.getElementById('scan-detail-overlay').style.display = 'none'; }, 200); selectedScanId = null; } async function saveOwnerNotes(id) { const notes = document.getElementById('scan-owner-notes')?.value || ''; try { await api('/api/room-scans/' + id, { method: 'PATCH', body: { owner_notes: notes } }); const idx = scans.findIndex(s => s.id === id); if (idx >= 0) scans[idx].owner_notes = notes; toast('Notes saved', 'success'); } catch (err) { toast('Failed to save notes', 'error'); } } async function markScanReviewed(id) { try { const updated = await api('/api/room-scans/' + id, { method: 'PATCH', body: { status: 'reviewed', owner_reviewed: true } }); const idx = scans.findIndex(s => s.id === id); if (idx >= 0) scans[idx] = { ...scans[idx], ...updated }; renderScans(); toast('Marked as reviewed', 'success'); } catch (err) { toast('Failed to update', 'error'); } } async function markScanBooked(id) { try { const updated = await api('/api/room-scans/' + id, { method: 'PATCH', body: { status: 'booked' } }); const idx = scans.findIndex(s => s.id === id); if (idx >= 0) scans[idx] = { ...scans[idx], ...updated }; renderScans(); toast('Marked as booked 🏆', 'success'); } catch (err) { toast('Failed to update', 'error'); } } async function dismissScan(id) { if (!confirm('Dismiss this scan? It will be hidden from the list.')) return; try { await api('/api/room-scans/' + id, { method: 'PATCH', body: { status: 'dismissed' } }); scans = scans.filter(s => s.id !== id); renderScans(); toast('Scan dismissed', 'success'); } catch (err) { toast('Failed to dismiss', 'error'); } } function openSendQuote(id, tier) { selectedScanId = id; const scan = scans.find(s => s.id === id); if (!scan) return; const gold = parseTier(scan.gold_estimate); const platinum = parseTier(scan.platinum_estimate); // Default to best-profit tier const defaultTier = tier || (gold && platinum && platinum.profit_dollar > gold.profit_dollar ? 'platinum' : 'gold'); const tiers = ['silver', 'gold', 'platinum']; const opts = tiers.map(t => { const te = parseTier(scan[t + '_estimate']); if (!te) return ''; return ''; }).join(''); if (!scan.customer_email) { toast('This scan has no customer email — cannot send quote', 'error'); return; } const overlay = document.createElement('div'); overlay.className = 'modal-overlay active'; overlay.id = 'send-quote-overlay'; overlay.innerHTML = ''; document.body.appendChild(overlay); } async function confirmSendQuote(id) { const selected = document.querySelector('input[name="quote-tier"]:checked'); if (!selected) { toast('Select a tier', 'error'); return; } const tier = selected.value; try { await api('/api/room-scans/' + id + '/send-quote', { method: 'POST', body: { selected_tier: tier } }); document.getElementById('send-quote-overlay')?.remove(); const idx = scans.findIndex(s => s.id === id); if (idx >= 0) { scans[idx].status = 'quoted'; scans[idx].customer_preferred_tier = tier; } renderScans(); toast('Quote sent! ✓', 'success'); } catch (err) { toast('Failed to send quote', 'error'); } } function openRecalculate(id) { const overlay = document.createElement('div'); overlay.className = 'modal-overlay active'; overlay.id = 'recalc-overlay'; const markupPanel = document.getElementById('markup-panel'); const silverPct = parseInt(document.getElementById('mu-silver')?.value || 35); const goldPct = parseInt(document.getElementById('mu-gold')?.value || 42); const platPct = parseInt(document.getElementById('mu-platinum')?.value || 52); overlay.innerHTML = ''; document.body.appendChild(overlay); } async function confirmRecalculate(id) { const silver = parseFloat(document.getElementById('rc-silver')?.value || 35) / 100; const gold = parseFloat(document.getElementById('rc-gold')?.value || 42) / 100; const platinum = parseFloat(document.getElementById('rc-platinum')?.value || 52) / 100; try { const result = await api('/api/room-scans/' + id + '/recalculate', { method: 'POST', body: { markup_overrides: { silver, gold, platinum } } }); document.getElementById('recalc-overlay')?.remove(); const idx = scans.findIndex(s => s.id === id); if (idx >= 0) { scans[idx].silver_estimate = result.estimates.silver; scans[idx].gold_estimate = result.estimates.gold; scans[idx].platinum_estimate = result.estimates.platinum; } renderScans(); if (selectedScanId === id) openScanDetail(id); toast('Estimates recalculated ✓', 'success'); } catch (err) { toast('Failed to recalculate', 'error'); } } function applyMarkupsToScan() { if (!selectedScanId) { toast('Open a scan detail first, then click Adjust Margins', 'error'); return; } openRecalculate(selectedScanId); } function toggleAdjustMarkupsPanel() { const panel = document.getElementById('markup-panel'); panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; } // ── Inline labor edit: recalc totals in DOM without API call ── function updateTierCalc(tierName, id) { const scan = scans.find(s => s.id === id); if (!scan) return; const tier = parseTier(scan[tierName + '_estimate']); if (!tier) return; const laborInput = document.getElementById('labor-' + tierName + '-' + id); if (!laborInput) return; const newLabor = parseFloat(laborInput.value) || 0; const newTotal = (tier.material_cost || 0) + newLabor; const markup = (tier.markup_pct || 35) / 100; const newPrice = Math.round(newTotal * (1 + markup)); const newProfit = newPrice - newTotal; const newProfitPct = newPrice > 0 ? Math.round((newProfit / newPrice) * 100) : 0; const totalEl = document.getElementById('total-cost-' + tierName + '-' + id); const chargeEl = document.getElementById('charge-' + tierName + '-' + id); const profitDollarEl = document.getElementById('profit-dollar-' + tierName + '-' + id); const profitPctEl = document.getElementById('profit-pct-' + tierName + '-' + id); if (totalEl) totalEl.textContent = '$' + newTotal; if (chargeEl) chargeEl.textContent = '$' + newPrice; if (profitDollarEl) profitDollarEl.textContent = '$' + newProfit; if (profitPctEl) profitPctEl.textContent = '(' + newProfitPct + '%)'; } // ── Save edited labor to DB via PATCH ── async function saveTierEdits(tierName, id) { const scan = scans.find(s => s.id === id); if (!scan) return; const tier = parseTier(scan[tierName + '_estimate']); if (!tier) return; const laborInput = document.getElementById('labor-' + tierName + '-' + id); if (!laborInput) return; const newLabor = parseFloat(laborInput.value) || 0; const newTotal = (tier.material_cost || 0) + newLabor; const markup = (tier.markup_pct || 35) / 100; const newPrice = Math.round(newTotal * (1 + markup)); const newProfit = newPrice - newTotal; const newProfitPct = newPrice > 0 ? Math.round((newProfit / newPrice) * 100) : 0; const updatedTier = { ...tier, labor_cost: newLabor, total_cost: Math.round(newTotal), suggested_price: newPrice, profit_dollar: newProfit, profit_pct: newProfitPct, roi_color: newProfitPct >= 30 ? 'green' : newProfitPct >= 20 ? 'yellow' : 'red', }; const patchBody = {}; patchBody[tierName + '_estimate'] = updatedTier; try { await api('/api/room-scans/' + id, { method: 'PATCH', body: patchBody }); const idx = scans.findIndex(s => s.id === id); if (idx >= 0) scans[idx][tierName + '_estimate'] = updatedTier; toast(tierName.charAt(0).toUpperCase() + tierName.slice(1) + ' estimates saved ✓', 'success'); } catch (err) { toast('Failed to save', 'error'); } } // ── Convert scan to job: create/find customer then prefill job modal ── async function convertScanToJob(id) { const scan = scans.find(s => s.id === id); if (!scan) return; const gold = parseTier(scan.gold_estimate); const suggestedAmount = gold ? gold.suggested_price : null; // Show convert modal const overlay = document.createElement('div'); overlay.className = 'modal-overlay active'; overlay.id = 'convert-job-overlay'; const projectLabel = (scan.project_type || '').replace(/_/g, ' '); const projectFormatted = projectLabel.charAt(0).toUpperCase() + projectLabel.slice(1); overlay.innerHTML = ''; document.body.appendChild(overlay); } async function confirmConvertToJob(scanId) { const nameVal = document.getElementById('ctj-name')?.value?.trim(); const titleVal = document.getElementById('ctj-title')?.value?.trim(); if (!nameVal) { toast('Customer name is required', 'error'); return; } if (!titleVal) { toast('Job title is required', 'error'); return; } const btn = document.getElementById('ctj-submit-btn'); btn.disabled = true; btn.textContent = 'Creating…'; try { // Find or create customer let customer = customers.find(c => c.name.trim().toLowerCase() === nameVal.toLowerCase()); if (!customer) { customer = await api('/api/customers', { method: 'POST', body: { name: nameVal, phone: document.getElementById('ctj-phone')?.value?.trim() || null, email: document.getElementById('ctj-email')?.value?.trim() || null, } }); // Refresh customers list customers = await api('/api/customers'); populateCustomerDropdown(); } const scan = scans.find(s => s.id === scanId); const projectType = (scan?.project_type || '').replace(/_/g, ' '); await api('/api/jobs', { method: 'POST', body: { customer_id: customer.id, title: titleVal, service_type: projectType || 'General Repair', status: 'quoted', quoted_amount: parseFloat(document.getElementById('ctj-amount')?.value) || null, notes: document.getElementById('ctj-notes')?.value?.trim() || null, } }); // Mark scan as booked await api('/api/room-scans/' + scanId, { method: 'PATCH', body: { status: 'booked' } }); const idx = scans.findIndex(s => s.id === scanId); if (idx >= 0) scans[idx].status = 'booked'; document.getElementById('convert-job-overlay')?.remove(); closeScanDetail(); renderScans(); // Reload jobs const jobsData = await api('/api/jobs'); jobs = jobsData; renderJobs(); renderSchedule(); renderPipeline(); toast('Job created ✓ — scan marked as booked 🏆', 'success'); } catch (err) { btn.disabled = false; btn.textContent = 'Create Job →'; toast(err.message || 'Failed to create job', 'error'); } } // ── Analytics panel toggle + render ── function toggleScansAnalytics() { const panel = document.getElementById('scans-analytics-panel'); const isHidden = panel.style.display === 'none'; panel.style.display = isHidden ? 'block' : 'none'; if (isHidden) renderScansAnalytics(); } function renderScansAnalytics() { // Stats from already-loaded data const statsRes = document.getElementById('scan-stat-total'); const total = parseInt(statsRes?.textContent || 0); const pending = parseInt(document.getElementById('scan-stat-pending')?.textContent || 0); const quoted = parseInt(document.getElementById('scan-stat-quoted')?.textContent || 0); const booked = parseInt(document.getElementById('scan-stat-booked')?.textContent || 0); // Use scans array for time-based counts const now = Date.now(); const last7 = scans.filter(s => s.status !== 'dismissed' && (now - new Date(s.created_at).getTime()) < 7 * 86400000).length; const last30 = scans.filter(s => s.status !== 'dismissed' && (now - new Date(s.created_at).getTime()) < 30 * 86400000).length; document.getElementById('sa-7d').textContent = last7; document.getElementById('sa-30d').textContent = last30; document.getElementById('sa-total').textContent = total || scans.filter(s => s.status !== 'dismissed').length; // Funnel const funnelEl = document.getElementById('sa-funnel'); const funnelSteps = [ { label: 'Submitted', count: total || scans.length, color: '#6366f1' }, { label: 'Reviewed', count: parseInt(document.getElementById('scan-stat-quoted')?.textContent || 0) + booked + (scans.filter(s => s.status === 'reviewed').length), color: '#f59e0b' }, { label: 'Quoted', count: quoted + booked, color: '#f97316' }, { label: 'Booked', count: booked, color: '#10b981' }, ]; const maxCount = funnelSteps[0].count || 1; funnelEl.innerHTML = funnelSteps.map((step, i) => { const pct = maxCount > 0 ? Math.round((step.count / maxCount) * 100) : 0; const convRate = i > 0 && funnelSteps[i-1].count > 0 ? Math.round((step.count / funnelSteps[i-1].count) * 100) : 100; return '
    ' + '
    ' + '' + step.label + '' + '' + step.count + (i > 0 ? ' (' + convRate + '%)' : '') + '' + '
    ' + '
    ' + '
    ' + '
    ' + '
    '; }).join(''); // Project type breakdown const projectEl = document.getElementById('sa-project-types'); const projectCounts = {}; scans.filter(s => s.status !== 'dismissed').forEach(s => { const pt = s.project_type || 'unknown'; projectCounts[pt] = (projectCounts[pt] || 0) + 1; }); const sortedProjects = Object.entries(projectCounts).sort((a, b) => b[1] - a[1]); const maxPt = sortedProjects[0]?.[1] || 1; if (sortedProjects.length === 0) { projectEl.innerHTML = '
    No data yet
    '; } else { const colors = ['#6366f1','#f59e0b','#10b981','#f97316','#3b82f6','#a855f7','#ec4899']; projectEl.innerHTML = sortedProjects.slice(0, 7).map(([pt, count], i) => { const label = pt.replace(/_/g, ' '); const labelFmt = label.charAt(0).toUpperCase() + label.slice(1); const pct = Math.round((count / maxPt) * 100); return '
    ' + '
    ' + '' + escHtml(labelFmt) + '' + '' + count + '' + '
    ' + '
    ' + '
    ' + '
    ' + '
    '; }).join(''); } } function toggleAdjustMarkupsPanel() { const panel = document.getElementById('markup-panel'); panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; } // ════════════════════════════════════════════════════════════ // UTIL // ════════════════════════════════════════════════════════════ function escHtml(s) { if (!s) return ''; var div = document.createElement('div'); div.textContent = s; return div.innerHTML; } // ════════════════════════════════════════════════════════════ // BOOKINGS // ════════════════════════════════════════════════════════════ let allBookings = []; let bookingsFilter = 'upcoming'; const SLOT_LABELS = { '09:00':'9:00 AM','10:00':'10:00 AM','11:00':'11:00 AM','12:00':'12:00 PM', '13:00':'1:00 PM','14:00':'2:00 PM','15:00':'3:00 PM','16:00':'4:00 PM' }; async function loadBookings() { document.getElementById('bookings-loading').style.display = 'block'; document.getElementById('bookings-empty').style.display = 'none'; document.getElementById('bookings-list').innerHTML = ''; try { allBookings = await api('/api/bookings'); updateBookingStats(); renderBookings(); } catch (e) { document.getElementById('bookings-loading').style.display = 'none'; document.getElementById('bookings-empty').style.display = 'block'; document.getElementById('bookings-empty').textContent = 'Failed to load bookings.'; } } function updateBookingStats() { const today = new Date(); today.setHours(0,0,0,0); const upcoming = allBookings.filter(b => { const [y,m,d] = b.appointment_date.split('-').map(Number); return new Date(y, m-1, d) >= today && b.status !== 'cancelled'; }); document.getElementById('bk-stat-upcoming').textContent = upcoming.length; document.getElementById('bk-stat-confirmed').textContent = allBookings.filter(b => b.status === 'confirmed').length; document.getElementById('bk-stat-pending').textContent = allBookings.filter(b => b.status === 'pending').length; document.getElementById('bk-stat-total').textContent = allBookings.length; const pendingCount = allBookings.filter(b => b.status === 'pending').length; const badge = document.getElementById('bookings-badge'); if (pendingCount > 0) { badge.style.display = ''; badge.textContent = pendingCount; } else badge.style.display = 'none'; } function filterBookings(filter) { bookingsFilter = filter; ['upcoming','all','pending','confirmed','completed','cancelled'].forEach(f => { const btn = document.getElementById('bkf-' + f); if (btn) { btn.className = f === filter ? 'btn btn-primary' : 'btn btn-secondary'; btn.style.fontSize = '13px'; } }); renderBookings(); } function renderBookings() { document.getElementById('bookings-loading').style.display = 'none'; const today = new Date(); today.setHours(0,0,0,0); let filtered = allBookings; if (bookingsFilter === 'upcoming') { filtered = allBookings.filter(b => { const [y,m,d] = b.appointment_date.split('-').map(Number); return new Date(y, m-1, d) >= today && b.status !== 'cancelled'; }); } else if (bookingsFilter !== 'all') { filtered = allBookings.filter(b => b.status === bookingsFilter); } if (filtered.length === 0) { document.getElementById('bookings-empty').style.display = 'block'; document.getElementById('bookings-empty').textContent = 'No bookings in this view.'; document.getElementById('bookings-list').innerHTML = ''; return; } document.getElementById('bookings-empty').style.display = 'none'; const STATUS_COLORS = { pending: '#f5a623', confirmed: '#10b981', completed: '#6366f1', cancelled: '#6b7280' }; document.getElementById('bookings-list').innerHTML = filtered.map(b => { const [y,m,d] = b.appointment_date.split('-').map(Number); const dt = new Date(y, m-1, d); const dateLabel = dt.toLocaleDateString('en-US', { weekday:'short', month:'short', day:'numeric', year:'numeric' }); const timeLabel = SLOT_LABELS[b.appointment_time] || b.appointment_time; const color = STATUS_COLORS[b.status] || '#8891aa'; const isPast = dt < today; return `
    ${escHtml(b.customer_name)} ${b.status}
    📅 ${dateLabel} · ${timeLabel} 🔧 ${escHtml(b.service_type)}
    📍 ${escHtml(b.address)}
    📞 ${escHtml(b.customer_phone)} ✉️ ${escHtml(b.customer_email)}
    ${b.description ? `
    📝 ${escHtml(b.description)}
    ` : ''}
    ${b.status === 'pending' ? `` : ''} ${b.status !== 'cancelled' && b.status !== 'completed' ? `` : ''} ${b.status === 'confirmed' ? `` : ''} ${b.status !== 'cancelled' && b.status !== 'completed' ? `` : ''}
    `; }).join(''); } async function updateBookingStatus(id, status) { try { const res = await fetch(`/api/bookings/${id}/status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) }); if (!res.ok) { alert('Failed to update booking.'); return; } await loadBookings(); } catch (e) { alert('Network error.'); } } function openRescheduleModal(id, date, time) { document.getElementById('rs-booking-id').value = id; document.getElementById('rs-date').value = date; document.getElementById('rs-time').value = time; document.getElementById('rs-error').style.display = 'none'; document.getElementById('reschedule-modal').style.display = 'flex'; } function closeRescheduleModal() { document.getElementById('reschedule-modal').style.display = 'none'; } async function submitReschedule() { const id = document.getElementById('rs-booking-id').value; const date = document.getElementById('rs-date').value; const time = document.getElementById('rs-time').value; const errEl = document.getElementById('rs-error'); errEl.style.display = 'none'; if (!date || !time) { errEl.textContent = 'Date and time required.'; errEl.style.display = 'block'; return; } try { const res = await fetch(`/api/bookings/${id}/reschedule`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appointment_date: date, appointment_time: time }) }); if (!res.ok) { const data = await res.json(); errEl.textContent = data.error || 'Failed to reschedule.'; errEl.style.display = 'block'; return; } closeRescheduleModal(); await loadBookings(); } catch (e) { errEl.textContent = 'Network error.'; errEl.style.display = 'block'; } } // ════════════════════════════════════════════════════════════ // NOTIFICATION CENTER // ════════════════════════════════════════════════════════════ let _notifOpen = false; let _notifPollInterval = null; let _lastUnreadCount = null; // Subtle chime using Web Audio API (only fires after user interaction) let _audioCtx = null; function _playChime() { try { if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const ctx = _audioCtx; const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.type = 'sine'; osc.frequency.setValueAtTime(880, ctx.currentTime); osc.frequency.exponentialRampToValueAtTime(660, ctx.currentTime + 0.12); gain.gain.setValueAtTime(0.18, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.35); osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.4); } catch (_) {} } function _notifTypeIcon(type) { const icons = { quote_request: '📋', room_scan: '🔍', booking: '📅', email: '✉️' }; return icons[type] || '🔔'; } function _timeAgo(isoStr) { const diff = Date.now() - new Date(isoStr).getTime(); const m = Math.floor(diff / 60000); if (m < 1) return 'just now'; if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; return `${Math.floor(h / 24)}d ago`; } function _renderNotifList(notifications) { const list = document.getElementById('notif-list'); if (!list) return; if (!notifications || notifications.length === 0) { list.innerHTML = `
    🔔
    No notifications yet
    `; return; } list.innerHTML = notifications.map(n => { const tab = (n.data && n.data.tab) || ''; return `
    ${_notifTypeIcon(n.type)}
    ${escHtml(n.title)}
    ${escHtml(n.body)}
    ${_timeAgo(n.created_at)}
    `; }).join(''); } async function _loadNotifications() { try { const res = await fetch('/api/notifications'); if (!res.ok) return; const data = await res.json(); const badge = document.getElementById('notif-badge'); const btn = document.getElementById('notif-btn'); // Play chime + pulse if new unread arrived since last check if (_lastUnreadCount !== null && data.unread_count > _lastUnreadCount) { _playChime(); if (btn) { btn.classList.remove('pulse'); void btn.offsetWidth; // force reflow so animation replays btn.classList.add('pulse'); btn.addEventListener('animationend', () => btn.classList.remove('pulse'), { once: true }); } } _lastUnreadCount = data.unread_count; if (badge) { if (data.unread_count > 0) { badge.textContent = data.unread_count > 99 ? '99+' : data.unread_count; badge.classList.add('show'); } else { badge.classList.remove('show'); } } if (_notifOpen) { _renderNotifList(data.notifications); } } catch (_) {} } async function _handleNotifClick(id, tab) { try { await fetch(`/api/notifications/read/${id}`, { method: 'POST' }); } catch (_) {} // Update dot immediately without waiting for full reload const item = document.querySelector(`.notif-item[data-id="${id}"]`); if (item) item.classList.remove('unread'); // Navigate to the relevant dashboard tab if (tab) { const navLink = document.querySelector(`.topbar-nav a[data-view="${tab}"]`); if (navLink) navLink.click(); } _toggleNotifDropdown(false); await _loadNotifications(); } function _toggleNotifDropdown(forceState) { const dropdown = document.getElementById('notif-dropdown'); if (!dropdown) return; _notifOpen = forceState !== undefined ? forceState : !_notifOpen; dropdown.classList.toggle('open', _notifOpen); if (_notifOpen) { _loadNotifications(); } } // Bell button toggle const _notifBtn = document.getElementById('notif-btn'); if (_notifBtn) { _notifBtn.addEventListener('click', (e) => { e.stopPropagation(); _toggleNotifDropdown(); }); } // Mark all read const _notifMarkAll = document.getElementById('notif-mark-all'); if (_notifMarkAll) { _notifMarkAll.addEventListener('click', async (e) => { e.stopPropagation(); try { await fetch('/api/notifications/read-all', { method: 'POST' }); await _loadNotifications(); } catch (_) {} }); } // Close dropdown on outside click document.addEventListener('click', (e) => { const wrap = document.getElementById('notif-wrap'); if (_notifOpen && wrap && !wrap.contains(e.target)) { _toggleNotifDropdown(false); } }); // Initialize audio context on first interaction (browser autoplay requirement) document.addEventListener('click', () => { if (!_audioCtx) { try { _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch (_) {} } }, { once: true }); function _startNotifPolling() { _loadNotifications(); // immediate on dashboard open _notifPollInterval = setInterval(_loadNotifications, 30000); // poll every 30s } // ════════════════════════════════════════════════════════════ // PUNCH LISTS // ════════════════════════════════════════════════════════════ let punchLists = []; let currentPunchList = null; // { ...list, items: [] } async function loadPunchLists() { document.getElementById('pl-loading').style.display = 'block'; document.getElementById('pl-empty').style.display = 'none'; document.getElementById('pl-list-container').innerHTML = ''; try { punchLists = await api('/api/punch-lists'); updatePunchListStats(); renderPunchListGrid(); } catch (e) { document.getElementById('pl-loading').style.display = 'none'; document.getElementById('pl-empty').style.display = 'block'; document.getElementById('pl-empty').innerHTML = '
    Failed to load punch lists.
    '; } } function updatePunchListStats() { document.getElementById('pl-stat-total').textContent = punchLists.length; document.getElementById('pl-stat-draft').textContent = punchLists.filter(l => l.status === 'draft').length; document.getElementById('pl-stat-active').textContent = punchLists.filter(l => l.status === 'active').length; document.getElementById('pl-stat-completed').textContent = punchLists.filter(l => l.status === 'completed').length; } function plStatusBadge(status) { const map = { draft: { bg: 'rgba(245,166,35,0.12)', color: 'var(--amber)', label: '🟡 Draft' }, active: { bg: 'rgba(16,185,129,0.12)', color: 'var(--green)', label: '🟢 Active' }, completed: { bg: 'rgba(99,102,241,0.12)', color: 'var(--blue-accent)', label: '✅ Completed' }, }; const s = map[status] || map.draft; return `${s.label}`; } function renderPunchListGrid() { document.getElementById('pl-loading').style.display = 'none'; if (punchLists.length === 0) { document.getElementById('pl-empty').style.display = 'block'; return; } document.getElementById('pl-empty').style.display = 'none'; document.getElementById('pl-list-container').innerHTML = punchLists.map(l => { const total = parseFloat(l.total_price || 0); const itemCount = parseInt(l.item_count || 0); const doneCount = parseInt(l.done_count || 0); const pct = itemCount > 0 ? Math.round(doneCount / itemCount * 100) : 0; const dt = new Date(l.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const meta = [l.job_title ? `📌 ${escHtml(l.job_title)}` : '', l.customer_name ? `👤 ${escHtml(l.customer_name)}` : ''].filter(Boolean).join(' · '); return `
    ${escHtml(l.title)} ${plStatusBadge(l.status)}
    ${meta ? `
    ${meta}
    ` : ''}
    📅 ${dt} ${itemCount} item${itemCount !== 1 ? 's' : ''} · ${doneCount} done ${total > 0 ? `${formatMoney(total)}` : ''}
    ${itemCount > 0 ? `
    ${pct}% complete
    ` : ''}
    `; }).join(''); } async function openPunchListDetail(id) { document.getElementById('pl-detail-overlay').style.display = 'block'; document.getElementById('pl-detail-items').innerHTML = '
    Loading…
    '; document.getElementById('pl-detail-title').textContent = 'Loading…'; try { const data = await api('/api/punch-lists/' + id); currentPunchList = data; renderPunchListDetail(); } catch (e) { document.getElementById('pl-detail-items').innerHTML = '
    Failed to load.
    '; } } function renderPunchListDetail() { const l = currentPunchList; document.getElementById('pl-detail-title').textContent = l.title; const meta = [l.job_title ? `📌 ${l.job_title}` : '', l.customer_name ? `👤 ${l.customer_name}` : ''].filter(Boolean).join(' · '); document.getElementById('pl-detail-meta').textContent = meta || ''; document.getElementById('pl-detail-status-badge').innerHTML = plStatusBadge(l.status); document.getElementById('pl-detail-status-select').value = l.status; const items = (l.items || []).filter(i => i.status !== 'removed'); const total = items.reduce((s, i) => s + parseFloat(i.price || 0), 0); const done = items.filter(i => i.status === 'done').length; const pct = items.length > 0 ? Math.round(done / items.length * 100) : 0; document.getElementById('pl-detail-item-count').textContent = items.length; document.getElementById('pl-detail-done-count').textContent = done; document.getElementById('pl-detail-progress-bar').style.width = pct + '%'; document.getElementById('pl-detail-total-price').textContent = formatMoney(total); if (items.length === 0) { document.getElementById('pl-detail-items').innerHTML = '
    No items yet. Add the first item above.
    '; return; } const statusCycle = { pending: 'in_progress', in_progress: 'done', done: 'pending', approved: 'in_progress' }; const statusLabel = { pending: '⬜ Pending', approved: '✔ Approved', in_progress: '🔄 In Progress', done: '✅ Done', removed: '✕ Removed' }; const statusColor = { pending: 'var(--text-muted)', approved: 'var(--green)', in_progress: 'var(--amber)', done: 'var(--green)', removed: 'var(--red)' }; document.getElementById('pl-detail-items').innerHTML = items.map(item => { const nextStatus = statusCycle[item.status] || 'pending'; const color = statusColor[item.status] || 'var(--text-muted)'; return `
    ${escHtml(item.title)}
    ${item.description ? `
    ${escHtml(item.description)}
    ` : ''}
    ${statusLabel[item.status] || item.status} ${item.added_by === 'customer' ? 'Customer added' : ''} ${item.requires_approval ? 'Needs approval' : ''}
    ${item.price ? `${formatMoney(item.price)}` : ''}
    `; }).join(''); } async function addPunchListItem() { const titleEl = document.getElementById('pl-new-item-title'); const priceEl = document.getElementById('pl-new-item-price'); const title = titleEl.value.trim(); if (!title) { titleEl.focus(); return; } const price = priceEl.value ? parseFloat(priceEl.value) : null; try { const item = await api('/api/punch-lists/' + currentPunchList.id + '/items', { method: 'POST', body: { title, price }, }); currentPunchList.items = currentPunchList.items || []; currentPunchList.items.push(item); titleEl.value = ''; priceEl.value = ''; renderPunchListDetail(); // Refresh the list grid in background api('/api/punch-lists').then(d => { punchLists = d; updatePunchListStats(); renderPunchListGrid(); }).catch(() => {}); } catch (e) { toast('Failed to add item', 'error'); } } async function cycleItemStatus(itemId, newStatus) { try { const updated = await api('/api/punch-lists/' + currentPunchList.id + '/items/' + itemId, { method: 'PUT', body: { status: newStatus }, }); const idx = currentPunchList.items.findIndex(i => i.id === itemId); if (idx >= 0) currentPunchList.items[idx] = { ...currentPunchList.items[idx], ...updated }; renderPunchListDetail(); api('/api/punch-lists').then(d => { punchLists = d; updatePunchListStats(); renderPunchListGrid(); }).catch(() => {}); } catch (e) { toast('Failed to update item', 'error'); } } async function removePunchListItem(itemId) { try { await api('/api/punch-lists/' + currentPunchList.id + '/items/' + itemId, { method: 'DELETE' }); currentPunchList.items = currentPunchList.items.filter(i => i.id !== itemId); renderPunchListDetail(); api('/api/punch-lists').then(d => { punchLists = d; updatePunchListStats(); renderPunchListGrid(); }).catch(() => {}); } catch (e) { toast('Failed to remove item', 'error'); } } async function updatePunchListStatus(newStatus) { try { const updated = await api('/api/punch-lists/' + currentPunchList.id, { method: 'PUT', body: { status: newStatus }, }); currentPunchList = { ...currentPunchList, ...updated }; document.getElementById('pl-detail-status-badge').innerHTML = plStatusBadge(newStatus); api('/api/punch-lists').then(d => { punchLists = d; updatePunchListStats(); renderPunchListGrid(); }).catch(() => {}); toast('Status updated'); } catch (e) { toast('Failed to update status', 'error'); } } function closePunchListDetail() { document.getElementById('pl-detail-overlay').style.display = 'none'; currentPunchList = null; } function copyShareLink() { if (!currentPunchList) return; copyShareLinkById(currentPunchList.share_token); } function copyShareLinkById(token) { const url = window.location.origin + '/punch-list/' + token; navigator.clipboard.writeText(url).then(() => toast('Link copied! ✓')).catch(() => { prompt('Copy this shareable link:', url); }); } async function deletePunchListById(id) { if (!confirm('Delete this punch list and all its items? This cannot be undone.')) return; try { await api('/api/punch-lists/' + id, { method: 'DELETE' }); punchLists = punchLists.filter(l => l.id !== id); updatePunchListStats(); renderPunchListGrid(); toast('Punch list deleted'); } catch (e) { toast('Failed to delete', 'error'); } } function openNewPunchListModal() { document.getElementById('pl-modal-title').value = ''; document.getElementById('pl-modal-status').value = 'draft'; // Populate job + customer dropdowns from already-loaded data const jobSel = document.getElementById('pl-modal-job'); jobSel.innerHTML = '' + jobs.map(j => ``).join(''); const custSel = document.getElementById('pl-modal-customer'); custSel.innerHTML = '' + customers.map(c => ``).join(''); document.getElementById('pl-new-modal').classList.add('active'); setTimeout(() => document.getElementById('pl-modal-title').focus(), 50); } function closePunchListModal() { document.getElementById('pl-new-modal').classList.remove('active'); } async function submitNewPunchList() { const title = document.getElementById('pl-modal-title').value.trim(); if (!title) { document.getElementById('pl-modal-title').focus(); return; } const job_id = document.getElementById('pl-modal-job').value || null; const customer_id = document.getElementById('pl-modal-customer').value || null; const status = document.getElementById('pl-modal-status').value; const btn = document.getElementById('pl-modal-submit'); btn.disabled = true; btn.textContent = 'Creating…'; try { const created = await api('/api/punch-lists', { method: 'POST', body: { title, job_id, customer_id, status } }); closePunchListModal(); punchLists.unshift({ ...created, item_count: 0, done_count: 0, total_price: 0, job_title: jobs.find(j => j.id == created.job_id)?.title || null, customer_name: customers.find(c => c.id == created.customer_id)?.name || null }); updatePunchListStats(); renderPunchListGrid(); toast('Punch list created ✓'); // Open it right away openPunchListDetail(created.id); } catch (e) { toast('Failed to create punch list', 'error'); } finally { btn.disabled = false; btn.textContent = 'Create List'; } } // ════════════════════════════════════════════════════════════ // INIT // ════════════════════════════════════════════════════════════ loadAll(); _startNotifPolling();