PageSourceSearch

https://ductdudes-platform.vercel.app/tech/js/assessment.js?v=9

js ductdudes-platform.vercel.app collected 2026-10-03 06:48:48 UTC 15,020 bytes, 359 lines download raw bytes

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;">&#128196;</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;">&times;</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>&#8987;</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>&#10003;</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>&#10007;</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>&#10007;</span> Error';
349      btn.style.background = 'rgba(224,72,72,0.2)';
350    }
351
352    btn.disabled = false;
353    setTimeout(() => {
354      btn.innerHTML = '<span>&#128663;</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.