1/** 2 * deficiency-list-edit.js â Full-page "Edit Deficiency List" form 3 * 4 * Reuses the deficiency-list-create.html template (with isEdit=true conditionals). 5 * Key differences from deficiency-list-create.js: 6 * - No draft persistence (no localStorage, no debounce, no DRAFT_KEY) 7 * - Pre-populates all fields from loaded project data 8 * - Submit calls PUT (updateDeficiencyList) instead of POST (createDeficiencyList) 9 * - Navigation goes to /deficiency-list/{id} (not /deficiency-list) 10 * - Dirty tracking via simple isDirty flag 11 */ 12 13import { projectsApi } from '../api/projects.js'; 14import { confirm } from '../utils/modal.js'; 15import { SearchableDropdown } from '../components/SearchableDropdown.js'; 16import { DatePicker } from '../components/DatePicker.js'; 17import { setLoading } from '../utils/dom.js'; 18import { validateForm, clearError } from '../utils/form-validation.js'; 19import { populateEditForm } from '../utils/form-population.js'; 20// CA-136: the create page owns the numeric descriptors, the rule builder and the trimmed reader; 21// this page reuses the same rules on the same template rather than authoring a second copy that 22// could drift. Both pages render `deficiency-list-create.html` (with `isEdit` conditionals). 23import { deficiencyListNumericRules, readDeficiencyListNumerics } from './deficiency-list-create.js'; 24import { DraftManager } from '../utils/draft-manager.js'; 25 26// Provincial lien period defaults (days) 27const LIEN_DEFAULTS = { 28 'ON': 60, 'AB': 60, 'BC': 55, 'SK': 60, 'MB': 40, 29 'QC': 30, 'NB': 60, 'NS': 60, 'PE': 60, 'NL': 60, 30 'NT': 60, 'YT': 60, 'NU': 60 31}; 32 33let abortController = null; 34let isDirty = false; 35let container = null; 36let currentId = null; 37let draft = null; 38 39// ======================================== 40// PUBLIC API 41// ======================================== 42 43export function initDeficiencyListEditPage(id, addOptions, data) { 44 abortController = new AbortController(); 45 const signal = abortController.signal; 46 container = document.getElementById('dl-create-page'); 47 currentId = id; 48 if (!container) return; 49 50 // Pre-populate form with existing data 51 populateForm(data, addOptions); 52 53 // Edit-backup: any in-flight edits from a prior session override the 54 // server values. Key is scoped per-record. Restore AFTER populateForm â 55 // draft wins so user edits aren't clobbered. 56 draft = new DraftManager({ 57 key: `sb_draft_deficiency_list_edit_${id}`, 58 container, 59 statusSelector: '#dl-draft-status', 60 DatePicker, 61 collectFn: () => collectFormData(), 62 onRestore: (data) => { 63 // Restore warranty custom toggle visibility 64 if (data.warrantyPeriod === 'custom') { 65 const customGroup = container.querySelector('#dl-warranty-custom-group'); 66 if (customGroup) customGroup.classList.remove('hidden'); 67 } 68 // Check date order after restore 69 checkDateOrder(); 70 isDirty = true; // a restored backup IS dirty work 71 }, 72 }); 73 draft.restore(); 74 75 // Wire events 76 wireFormEvents(signal); 77 wireProvinceToLienPeriod(signal); 78 wireWarrantyToggle(signal); 79 wireDateWarnings(signal); 80 wireBackButton(signal); 81 wireCancelButton(signal); 82 wireSubmitButton(signal); 83 84 // Navigation guard 85 window.addEventListener('beforeunload', onBeforeUnload); 86} 87 88export function destroyDeficiencyListEditPage() { 89 window.removeEventListener('beforeunload', onBeforeUnload); 90 91 draft?.destroy(); 92 draft = null; 93 94 if (container) { 95 SearchableDropdown.destroyAll(container); 96 } 97 98 abortController?.abort(); 99 abortController = null; 100 container = null; 101 isDirty = false; 102 currentId = null; 103} 104 105// ======================================== 106// FORM PRE-POPULATION 107// ======================================== 108 109function populateForm(data, addOptions) { 110 if (!data || !container) return; 111 112 // Field name mapping: API response key â form input name attr 113 const fieldMap = { 114 projectTitle: 'projectName', 115 projectNumber: 'projectNumber', 116 clientName: 'clientName', 117 clientContact: 'clientContact', 118 projectEmail: 'projectEmail', 119 projectAddress: 'siteAddress', 120 projectCity: 'city', 121 contractNum: 'contractNumber', 122 generalContractor: 'gcCompany', 123 siteSuperintendent: 'gcContact', 124 gcEmail: 'gcEmail', 125 gcPhone: 'gcPhone', 126 scopeOfWork: 'notes', 127 }; 128 129 // Map API â form names, then set text inputs 130 const mapped = {}; 131 for (const [apiKey, formName] of Object.entries(fieldMap)) { 132 if (data[apiKey] !== undefined && data[apiKey] !== null) { 133 mapped[formName] = data[apiKey]; 134 } 135 } 136 137 // Contract value (number) 138 if (data.projectBudget != null) { 139 mapped.contractValue = data.projectBudget; 140 } 141 142 // Holdback percentage 143 if (data.holdbackPercentage != null) { 144 mapped.holdbackPct = data.holdbackPercentage; 145 } 146 147 // Lien period 148 if (data.lienPeriodDays != null) { 149 mapped.lienPeriod = data.lienPeriodDays; 150 } 151 152 // Populate text inputs + textareas via shared utility 153 // (selects and dates handled separately below due to custom ID-based mapping) 154 populateEditForm(container, mapped, { 155 currencyFields: ['contractValue'],
156 }); 157 158 // Province select 159 if (data.projectProvince) { 160 const provinceEl = container.querySelector('#dl-province'); 161 if (provinceEl) { 162 provinceEl.value = data.projectProvince; 163 provinceEl.dispatchEvent(new Event('change', { bubbles: true })); 164 } 165 } 166 167 // Contract type select 168 if (data.contractType) { 169 const ctEl = container.querySelector('#dl-contract-type'); 170 if (ctEl) { 171 ctEl.value = data.contractType; 172 ctEl.dispatchEvent(new Event('change', { bubbles: true })); 173 } 174 } 175 176 // Warranty period â resolve to preset select value or custom 177 if (data.defaultWarrantyMonths != null) { 178 const warrantySelect = container.querySelector('#dl-warranty-period'); 179 const customGroup = container.querySelector('#dl-warranty-custom-group'); 180 const customInput = container.querySelector('#dl-custom-warranty'); 181 const presetValues = ['12', '24', '36', '60']; 182 const months = String(data.defaultWarrantyMonths); 183 184 if (warrantySelect) { 185 if (presetValues.includes(months)) { 186 warrantySelect.value = months; 187 } else { 188 warrantySelect.value = 'custom'; 189 if (customGroup) customGroup.classList.remove('hidden'); 190 if (customInput) customInput.value = months; 191 } 192 warrantySelect.dispatchEvent(new Event('change', { bubbles: true })); 193 } 194 } 195 196 // Team select dropdowns (user IDs) 197 if (data.principalUserId) { 198 const el = container.querySelector('#dl-principal'); 199 if (el) {
200 el.value = String(data.principalUserId); 201 el.dispatchEvent(new Event('change', { bubbles: true })); 202 } 203 } 204 if (data.managerUserId) { 205 const el = container.querySelector('#dl-pm'); 206 if (el) { 207 el.value = String(data.managerUserId); 208 el.dispatchEvent(new Event('change', { bubbles: true })); 209 } 210 } 211 if (data.leadInspectorUserId) { 212 const el = container.querySelector('#dl-inspector'); 213 if (el) { 214 el.value = String(data.leadInspectorUserId); 215 el.dispatchEvent(new Event('change', { bubbles: true })); 216 } 217 } 218 219 // Hidden fields â deficiency list type 220 if (data.deficiencyListType) { 221 const dtEl = container.querySelector('#dl-type'); 222 if (dtEl) dtEl.value = data.deficiencyListType; 223 } 224 225 // Date fields via DatePicker API 226 // API response key â form input name 227 const dateMap = { 228 submissionDueDate: 'substantialPerf', 229 anticipatedConstructionStart: 'constructionStart', 230 constructionCompletion: 'contractualCompletion', 231 anticipatedAwardDate: 'expectedCloseout', 232 }; 233 234 for (const [apiKey, formName] of Object.entries(dateMap)) { 235 const val = data[apiKey]; 236 if (val) { 237 const hiddenInput = container.querySelector(`input[name="${formName}"]`); 238 if (hiddenInput) { 239 const dp = DatePicker.getInstance(hiddenInput); 240 if (dp) dp.setValue(val); 241 } 242 } 243 } 244 245 // Check date order after populating 246 checkDateOrder(); 247} 248 249// ======================================== 250// FORM DATA COLLECTION 251// ======================================== 252 253function collectFormData() { 254 const data = {}; 255 if (!container) return data; 256 257 container.querySelectorAll('input[name], textarea[name]').forEach(el => { 258 data[el.name] = el.value; 259 }); 260 261 container.querySelectorAll('select[name]').forEach(el => { 262 data[el.name] = el.value; 263 }); 264 265 return data; 266} 267 268// ======================================== 269// EVENT WIRING 270// ======================================== 271 272function wireFormEvents(signal) { 273 container.addEventListener('input', (e) => { 274 isDirty = true; 275 draft?.scheduleSave(); 276 if (e.target.hasAttribute('data-required')) clearError(container, e.target); 277 }, { signal }); 278 279 container.addEventListener('change', (e) => { 280 isDirty = true; 281 draft?.scheduleSave(); 282 if (e.target.hasAttribute('data-required')) clearError(container, e.target); 283 }, { signal }); 284} 285 286function wireProvinceToLienPeriod(signal) { 287 const provinceInput = container.querySelector('#dl-province'); 288 const lienInput = container.querySelector('#dl-lien-period'); 289 if (!provinceInput || !lienInput) return; 290 291 provinceInput.addEventListener('change', () => { 292 const currentLien = lienInput.value; 293 if (currentLien && currentLien !== '60') return; 294 295 const province = provinceInput.value.trim().toUpperCase(); 296 if (LIEN_DEFAULTS[province] !== undefined) { 297 lienInput.value = LIEN_DEFAULTS[province]; 298 } 299 }, { signal }); 300} 301 302function wireWarrantyToggle(signal) { 303 const warrantySelect = container.querySelector('#dl-warranty-period'); 304 const customGroup = container.querySelector('#dl-warranty-custom-group'); 305 const customInput = container.querySelector('#dl-custom-warranty'); 306 if (!warrantySelect || !customGroup) return; 307 308 warrantySelect.addEventListener('change', () => { 309 const isCustom = warrantySelect.value === 'custom'; 310 customGroup.classList.toggle('hidden', !isCustom); 311 if (!isCustom && customInput) customInput.value = ''; 312 }, { signal }); 313} 314 315function wireDateWarnings(signal) { 316 container.addEventListener('change', (e) => { 317 const dateNames = ['constructionStart', 'substantialPerf', 'contractualCompletion', 'expectedCloseout']; 318 if (dateNames.includes(e.target.name)) { 319 checkDateOrder(); 320 } 321 }, { signal }); 322} 323 324function checkDateOrder() { 325 const warningEl = container.querySelector('#dl-date-warning'); 326 if (!warningEl) return; 327 328 const getDateVal = (name) => { 329 const el = container.querySelector(`[name="${name}"]`); 330 return el?.value ? new Date(el.value) : null; 331 }; 332 333 const dates = [ 334 getDateVal('constructionStart'), 335 getDateVal('substantialPerf'), 336 getDateVal('contractualCompletion'), 337 getDateVal('expectedCloseout')
338 ].filter(Boolean); 339 340 let outOfOrder = false; 341 for (let i = 1; i < dates.length; i++) { 342 if (dates[i] < dates[i - 1]) { 343 outOfOrder = true; 344 break; 345 } 346 } 347 348 warningEl.classList.toggle('hidden', !outOfOrder); 349} 350 351// ======================================== 352// NAVIGATION 353// ======================================== 354 355function wireBackButton(signal) { 356 container.querySelector('#btn-dl-back')?.addEventListener('click', () => { 357 handleCancel(); 358 }, { signal }); 359} 360 361function wireCancelButton(signal) { 362 container.querySelector('#btn-dl-cancel')?.addEventListener('click', () => { 363 handleCancel(); 364 }, { signal }); 365} 366 367async function handleCancel() { 368 if (isDirty) { 369 const confirmed = await confirm({ 370 title: 'Discard unsaved changes?', 371 message: 'Any changes you made will be lost.', 372 icon: 'trash-2', 373 confirmText: 'Discard', 374 confirmClass: 'btn-st-error', 375 // Page signal â the confirm auto-resolves false and closes on teardown, 376 // so it isn't orphaned over the next page. 377 signal: abortController?.signal, 378 }); 379 if (!confirmed) return; 380 draft?.clear(); 381 } 382 page(`/deficiency-list/${currentId}`); 383} 384 385function wireSubmitButton(signal) { 386 const btn = container.querySelector('#btn-dl-submit'); 387 if (!btn) return; 388 389 btn.addEventListener('click', () => handleSubmit(btn), { signal }); 390} 391 392async function handleSubmit(btn) { 393 // Round one: required fields AND every swept control's well-formedness, together 394 // (`data-validate="number"` in the template). Round two is the bounded half. Both run BEFORE 395 // the loading state, before the map and before the PUT. 396 // CA-146 â ONE round: presence, format and the numeric rules are judged in a single pass. 397 if (!validateForm(container, deficiencyListNumericRules(container))) return; 398 399 setLoading(btn, true, 'Saving...'); 400 401 try { 402 const data = collectFormData(); 403 const num = readDeficiencyListNumerics(data); 404 405 // Resolve warranty months â preset or custom. CA-136: same repair as the create page; the 406 // preset arm's `parseInt` reads an authored `<select>` option value and is ALREADY-SAFE. 407 let warrantyMonths = null; 408 if (data.warrantyPeriod === 'custom') { 409 warrantyMonths = num.customWarranty === '' ? null : Number(num.customWarranty); 410 } else if (data.warrantyPeriod) { 411 warrantyMonths = parseInt(data.warrantyPeriod); 412 } 413 414 await projectsApi.updateDeficiencyList(currentId, { 415 projectTitle: data.projectName, 416 projectEmail: data.projectEmail, 417 deficiencyListType: data.deficiencyListType || 'Substantial Performance', 418 419 // Section 1: Project Information 420 projectNumber: data.projectNumber || null, 421 clientName: data.clientName || null, 422 clientContact: data.clientContact || null, 423 projectAddress: data.siteAddress || null, 424 projectCity: data.city || null, 425 projectProvince: data.province || null, 426 427 // Section 2: Contract Details 428 contractType: data.contractType || null, 429 contractNumber: data.contractNumber || null, 430 // CA-136: exact `Number` after the gate. Blank contracts derived from 431 // `UpdateDeficiencyListRequest` - `ProjectBudget` is a NON-nullable `decimal` so blank 432 // is 0; `HoldbackPercentage` is `decimal?` so blank is null. 433 projectBudget: num.contractValue === '' ? 0 : Number(num.contractValue), 434 holdbackPercentage: num.holdbackPct === '' ? null : Number(num.holdbackPct), 435 generalContractor: data.gcCompany || null, 436 siteSuperintendent: data.gcContact || null, 437 gcEmail: data.gcEmail || null, 438 gcPhone: data.gcPhone || null, 439 440 // Section 3: Key Dates 441 anticipatedConstructionStart: data.constructionStart || null, 442 submissionDueDate: data.substantialPerf || null, 443 constructionCompletion: data.contractualCompletion || null, 444 anticipatedAwardDate: data.expectedCloseout || null, 445 // `LienPeriodDays` is `int?` -> blank is null. 446 lienPeriodDays: num.lienPeriod === '' ? null : Number(num.lienPeriod), 447 defaultWarrantyMonths: warrantyMonths, 448 449 // Section 4: Team Assignment 450 principalUserId: data.principal ? parseInt(data.principal) : null, 451 projectManagerUserId: data.pm ? parseInt(data.pm) : null, 452 leadInspectorUserId: data.inspector ? parseInt(data.inspector) : null, 453 454 // Section 5: Notes 455 scopeOfWork: data.notes || null, 456 }); 457 458 isDirty = false;
459 draft?.clear(); 460 window.showToast?.('Deficiency list updated', 'success', { description: data.projectName }); 461 page(`/deficiency-list/${currentId}`); 462 } catch (error) { 463 if (error.name === 'AbortError') return; 464 window.showToast?.('Failed to update project', 'error', { description: error.message }); 465 setLoading(btn, false); 466 } 467} 468 469// ======================================== 470// NAVIGATION GUARD 471// ======================================== 472 473function onBeforeUnload(e) { 474 if (isDirty) { 475 draft?.save(); // synchronous localStorage write â survives browser close 476 e.preventDefault(); 477 e.returnValue = ''; 478 } 479}
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.