1// =========================== 2// Assessment Detail Screen 3// =========================== 4 5const Assessment = { 6 _currentCategory: 'during', 7 8 async open(asmt) { 9 AppState.currentAssessment = asmt; 10 11 // Title badge 12 const badge = document.getElementById('asmtTitleBadge'); 13 if (badge) { 14 badge.textContent = asmt.title || ''; 15 badge.style.display = asmt.title ? 'block' : 'none'; 16 } 17 18 // Client / contact 19 document.getElementById('asmtClientDisplay').textContent = asmt.client_name || 'Assessment'; 20 21 // Address â tappable Google Maps link 22 const asmtAddressLink = document.getElementById('asmtAddressLink'); 23 const asmtAddressDisplay = document.getElementById('asmtAddressDisplay'); 24 const asmtAddress = asmt.address || ''; 25 if (asmtAddressDisplay) asmtAddressDisplay.textContent = asmtAddress || 'No address'; 26 if (asmtAddressLink) { 27 if (asmtAddress) { 28 const mapsUrl = getMapsUrl(asmtAddress); 29 asmtAddressLink.onclick = () => window.open(mapsUrl, '_blank'); 30 asmtAddressLink.style.cursor = 'pointer'; 31 if (asmtAddressDisplay) { asmtAddressDisplay.style.color = 'var(--dd-blue)'; asmtAddressDisplay.style.textDecoration = 'underline'; } 32 } else { 33 asmtAddressLink.onclick = null; 34 asmtAddressLink.style.cursor = 'default'; 35 if (asmtAddressDisplay) { asmtAddressDisplay.style.color = 'inherit'; asmtAddressDisplay.style.textDecoration = 'none'; } 36 } 37 } 38 39 // Call button â opens native dialer if phone present, otherwise grayed 40 const asmtPhoneBtn = document.getElementById('asmtPhone'); 41 if (asmtPhoneBtn) { 42 if (asmt.client_phone) { 43 asmtPhoneBtn.href = 'tel:' + asmt.client_phone; 44 asmtPhoneBtn.removeAttribute('aria-disabled'); 45 asmtPhoneBtn.style.opacity = ''; 46 asmtPhoneBtn.style.pointerEvents = ''; 47 } else { 48 asmtPhoneBtn.href = '#'; 49 asmtPhoneBtn.setAttribute('aria-disabled', 'true'); 50 asmtPhoneBtn.style.opacity = '0.45'; 51 asmtPhoneBtn.style.pointerEvents = 'none'; 52 } 53 } 54 55 // Map button â directs to address (same URL the address line uses) 56 const btnMapsNavAsmt = document.getElementById('btnMapsNavAsmt'); 57 if (btnMapsNavAsmt) { 58 if (asmtAddress) { 59 btnMapsNavAsmt.href = getMapsUrl(asmtAddress); 60 btnMapsNavAsmt.removeAttribute('aria-disabled'); 61 btnMapsNavAsmt.style.opacity = ''; 62 btnMapsNavAsmt.style.pointerEvents = ''; 63 } else { 64 btnMapsNavAsmt.href = '#'; 65 btnMapsNavAsmt.setAttribute('aria-disabled', 'true'); 66 btnMapsNavAsmt.style.opacity = '0.45'; 67 btnMapsNavAsmt.style.pointerEvents = 'none'; 68 } 69 } 70 71 const contactParts = [asmt.client_phone, asmt.client_email].filter(Boolean); 72 const contactEl = document.getElementById('asmtContactDisplay'); 73 if (contactEl) contactEl.textContent = contactParts.join(' | '); 74 75 // Window â start/end times are the source of truth. Mirror the 76 // schedule.js mapAssessmentFromDB rule: only show a labelled window 77 // when both arrival_window_start and arrival_window_end are present. 78 // Falls back to "Flexible / Anytime" otherwise (covers legacy rows 79 // where the DB default 'morning' got stamped on a flexible booking). 80 const fmtT = (t) => { 81 if (!t) return ''; 82 const [h, m] = String(t).split(':'); 83 const hour = parseInt(h, 10); 84 const min = m || '00'; 85 const ampm = hour >= 12 ? 'PM' : 'AM'; 86 const h12 = hour === 0 ? 12 : (hour > 12 ? hour - 12 : hour); 87 return `${h12}:${min} ${ampm}`; 88 }; 89 let winLabel; 90 if (asmt.arrival_window_start && asmt.arrival_window_end) { 91 winLabel = `${fmtT(asmt.arrival_window_start)} - ${fmtT(asmt.arrival_window_end)}`; 92 } else { 93 winLabel = 'Flexible / Anytime'; 94 } 95 document.getElementById('asmtWindowDisplay').textContent = winLabel; 96 97 // Admin notes 98 const notesCard = document.getElementById('asmtAdminNotesCard'); 99 const notesText = document.getElementById('asmtAdminNotesText'); 100 if (notesCard && notesText) { 101 if (asmt.notes_admin) { 102 notesText.textContent = asmt.notes_admin; 103 notesCard.style.display = 'block'; 104 } else { 105 notesCard.style.display = 'none'; 106 } 107 } 108 109 // Status bar + complete button 110 const isCompleted = asmt.status === 'completed'; 111 const statusLabel = document.getElementById('asmtStatusLabel'); 112 if (statusLabel) statusLabel.textContent = isCompleted ? 'Completed' : 'Assessment'; 113 const statusBar = document.getElementById('asmtStatusBar'); 114 if (statusBar) { 115 statusBar.className = 'job-status-bar' + (isCompleted ? ' completed' : ''); 116 } 117 118 const btnComplete = document.getElementById('btnCompleteAssessment'); 119 const completedMsg = document.getElementById('asmtCompletedMsg'); 120 if (btnComplete) btnComplete.style.display = isCompleted ? 'none' : 'block'; 121 if (completedMsg) completedMsg.style.display = isCompleted ? 'block' : 'none'; 122 123 // Pre-fill tech notes 124 const notesInput = document.getElementById('asmtTechNotesInput'); 125 if (notesInput) notesInput.value = asmt.notes_tech || ''; 126 127 // Load photos 128 this._resetPhotos(); 129 this._loadPhotos(asmt.id); 130 131 // Switch to details tab and show screen 132 this.switchTab('details'); 133 Screens.show('assessment'); 134 }, 135 136 switchTab(tabName) { 137 document.querySelectorAll('#screen-assessment .job-tab').forEach(btn => { 138 btn.classList.toggle('active', btn.dataset.asmtTab === tabName); 139 }); 140 document.getElementById('asmtTabDetails').style.display = tabName === 'details' ? 'block' : 'none'; 141 document.getElementById('asmtTabNotes').style.display = tabName === 'notes' ? 'block' : 'none'; 142 }, 143 144 _resetPhotos() { 145 ['before', 'during', 'after'].forEach(cat => { 146 const grid = document.getElementById(`asmtPhotoGrid${cat.charAt(0).toUpperCase() + cat.slice(1)}`); 147 const count = document.getElementById(`asmtPhotoCount${cat.charAt(0).toUpperCase() + cat.slice(1)}`); 148 if (grid) grid.innerHTML = ''; 149 if (count) count.textContent = '0'; 150 }); 151 const refCard = document.getElementById('asmtRefFilesCard'); 152 const refGrid = document.getElementById('asmtRefFilesGrid'); 153 if (refCard) refCard.style.display = 'none'; 154 if (refGrid) refGrid.innerHTML = ''; 155 this._currentCategory = 'during'; 156 }, 157 158 async _loadPhotos(assessmentId) { 159 try { 160 const photos = await DataService.media.getByAssessment(assessmentId); 161 photos.forEach(p => this._renderPhoto(p)); 162 } catch (e) { console.warn('Could not load assessment photos:', e); } 163 }, 164 165 _renderPhoto(p) { 166 const cat = p.category || 'during'; 167 168 // Admin reference files render in a dedicated card, not the photo grid 169 if (cat === 'admin_notes') { 170 const card = document.getElementById('asmtRefFilesCard'); 171 const refGrid = document.getElementById('asmtRefFilesGrid'); 172 if (!refGrid) return; 173 if (card) card.style.display = 'block'; 174 const url = p.file_url || ''; 175 const name = p.file_name || url.split('/').pop() || 'File'; 176 const isImg = p.media_type === 'image'; 177 const el = document.createElement(isImg ? 'img' : 'a'); 178 if (isImg) { 179 el.src = url; 180 el.loading = 'lazy'; 181 el.style.cssText = 'width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid #BFDBFE;cursor:pointer;'; 182 el.addEventListener('click', () => Assessment._openLightbox(url)); 183 } else { 184 el.href = url; 185 el.rel = 'noopener'; 186 el.style.cssText = 'display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;width:72px;height:72px;border-radius:6px;border:1px solid #BFDBFE;background:#EFF6FF;text-decoration:none;overflow:hidden;'; 187 el.innerHTML = '<span style="font-size:22px;">📄</span><span style="font-size:9px;color:#1D4ED8;text-align:center;word-break:break-all;padding:0 4px;line-height:1.2;">' + name.replace(/(.{10}).+/, '$1...') + '</span>'; 188 // iOS PWA standalone mode ignores target="_blank" â use window.open() so 189 // Safari opens the file outside the PWA on both iOS and Android. 190 el.addEventListener('click', (e) => { e.preventDefault(); window.open(url, '_blank'); }); 191 } 192 refGrid.appendChild(el); 193 return; 194 } 195 196 const capCat = cat.charAt(0).toUpperCase() + cat.slice(1); 197 const grid = document.getElementById(`asmtPhotoGrid${capCat}`); 198 const countEl = document.getElementById(`asmtPhotoCount${capCat}`); 199 if (!grid) return; 200 const thumb = document.createElement('div'); 201 thumb.className = 'photo-thumb'; 202 thumb.style.cursor = 'pointer'; 203 const img = document.createElement('img'); 204 img.src = p.file_url; 205 img.loading = 'lazy'; 206 thumb.appendChild(img); 207 thumb.addEventListener('click', () => Assessment._openLightbox(p.file_url)); 208 grid.appendChild(thumb); 209 if (countEl) countEl.textContent = grid.querySelectorAll('.photo-thumb').length; 210 }, 211 212 _openLightbox(url) {
213 const overlay = document.createElement('div'); 214 overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.92);z-index:9999;display:flex;align-items:center;justify-content:center;'; 215 overlay.innerHTML = `<img src="${url}" style="max-width:95vw;max-height:90vh;object-fit:contain;border-radius:8px;box-shadow:0 4px 40px rgba(0,0,0,0.6);"> 216 <button style="position:absolute;top:16px;right:20px;background:none;border:none;color:#fff;font-size:36px;cursor:pointer;line-height:1;">×</button>`; 217 overlay.addEventListener('click', () => overlay.remove()); 218 document.body.appendChild(overlay); 219 }, 220 221 async saveNotes() { 222 const asmt = AppState.currentAssessment; 223 if (!asmt) return; 224 const notes = document.getElementById('asmtTechNotesInput')?.value || ''; 225 const btn = document.getElementById('btnSaveAsmtNotes'); 226 if (btn) { btn.textContent = 'Saving...'; btn.disabled = true; } 227 try { 228 await DataService.assessments.update(asmt.id, { notes_tech: notes }); 229 AppState.currentAssessment.notes_tech = notes; 230 if (typeof showToast !== 'undefined') showToast('Notes saved.', 'success'); 231 else alert('Notes saved.'); 232 } catch (err) { 233 alert('Could not save notes: ' + err.message); 234 } finally { 235 if (btn) { btn.textContent = 'Save Notes'; btn.disabled = false; } 236 } 237 }, 238 239 async uploadPhoto(files) { 240 const asmt = AppState.currentAssessment; 241 if (!asmt || !files || !files.length) return; 242 for (const rawFile of files) { 243 // iPhones save photos as HEIC; convert to JPEG before upload so admin browsers 244 // (desktop Chrome/Edge) can render them. HeicHelper passes non-HEIC through. 245 const file = window.HeicHelper ? await window.HeicHelper.convertIfNeeded(rawFile) : rawFile; 246 try { 247 const p = await DataService.media.uploadForAssessment(file, asmt.id, AppState.tech?.id, this._currentCategory); 248 this._renderPhoto(p); 249 } catch (err) { 250 alert('Upload failed: ' + err.message); 251 } 252 } 253 }, 254 255 async complete() { 256 const asmt = AppState.currentAssessment; 257 if (!asmt) return; 258 const notes = document.getElementById('asmtTechNotesInput')?.value || ''; 259 if (!confirm('Mark this assessment as complete?')) return; 260 try { 261 await DataService.assessments.complete(asmt.id, notes); 262 AppState.currentAssessment.status = 'completed'; 263 AppState.currentAssessment.notes_tech = notes; 264 document.getElementById('btnCompleteAssessment').style.display = 'none'; 265 document.getElementById('asmtCompletedMsg').style.display = 'block'; 266 const statusLabel = document.getElementById('asmtStatusLabel'); 267 if (statusLabel) statusLabel.textContent = 'Completed'; 268 const statusBar = document.getElementById('asmtStatusBar'); 269 if (statusBar) statusBar.className = 'job-status-bar completed'; 270 } catch (err) { 271 alert('Could not complete assessment: ' + err.message); 272 } 273 }, 274 275 // ---- Communication Actions ---- 276 277 // Call client via native dialer (mirrors Job.callClient) 278 callClient() { 279 const asmt = AppState.currentAssessment; 280 if (!asmt || !asmt.client_phone) return; 281 window.location.href = 'tel:' + asmt.client_phone; 282 }, 283 284 // Send "On My Way" SMS to the assessment client. Mirrors Job.sendOnMyWay 285 // line-for-line; the only differences are the AppState pointer 286 // (currentAssessment vs currentJob), the flat client_phone/address access, 287 // and assessmentId vs jobId in the POST payload. 288 async sendOnMyWay(etaMinutes = 30) { 289 const asmt = AppState.currentAssessment; 290 if (!asmt) return; 291 292 if (!asmt.client_phone) { 293 App.toast('No phone number on file. Cannot send On My Way text.', 'warning'); 294 return; 295 } 296 297 const btn = document.getElementById('btnSendOnMyWayAsmt'); 298 btn.innerHTML = '<span>⌛</span> Sending...'; 299 btn.disabled = true; 300 301 const techName = AppState.tech.name || 'Your technician'; 302 303 let etaText; 304 if (etaMinutes <= 5) { 305 etaText = 'about 5 minutes'; 306 } else if (etaMinutes <= 10) { 307 etaText = 'about 10 minutes'; 308 } else if (etaMinutes <= 15) { 309 etaText = 'about 15 minutes'; 310 } else if (etaMinutes <= 30) {
311 etaText = 'approximately 30 minutes'; 312 } else if (etaMinutes <= 45) { 313 etaText = 'approximately 45 minutes'; 314 } else { 315 etaText = 'approximately 60 minutes'; 316 } 317 318 const address = asmt.address || ''; 319 const message = `Hello! This is ${techName} from DuctDudes. I am on my way to your location${address ? ` at ${address}` : ''}. Estimated arrival: ${etaText}. See you soon!`; 320 321 try { 322 const res = await DataService.staffFetch('/api/sms/send', { 323 method: 'POST', 324 headers: { 'Content-Type': 'application/json' }, 325 body: JSON.stringify({ 326 to: asmt.client_phone, 327 body: message, 328 from: AppState.tech.twilioNumber || null, 329 clientId: asmt.client_id || null, 330 technicianId: AppState.tech.id, 331 assessmentId: asmt.id, 332 }), 333 }); 334 const result = await res.json(); 335 336 if (result.success) { 337 btn.innerHTML = '<span>✓</span> Sent!'; 338 btn.style.background = 'rgba(108,197,24,0.3)'; 339 btn.style.borderColor = '#6cc518'; 340 console.log('Assessment On My Way SMS sent:', result.sid); 341 } else { 342 btn.innerHTML = '<span>✗</span> Failed'; 343 btn.style.background = 'rgba(224,72,72,0.2)'; 344 console.error('Assessment On My Way SMS failed:', result.error); 345 } 346 } catch (err) { 347 console.error('Assessment On My Way API error:', err); 348 btn.innerHTML = '<span>✗</span> Error'; 349 btn.style.background = 'rgba(224,72,72,0.2)'; 350 } 351 352 btn.disabled = false; 353 setTimeout(() => { 354 btn.innerHTML = '<span>🚗</span> On My Way'; 355 btn.style.background = ''; 356 btn.style.borderColor = ''; 357 }, 3000); 358 }, 359};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.