1/** 2 * post-construction-create.js â Full-page "New Post-Construction" form 3 * 4 * Features: 5 * - Draft persistence (localStorage auto-save) 6 * - Design Bible validation (red borders, shake, scroll-to-first) 7 * - Navigation guard (beforeunload) 8 * - Linked Contract Admin auto-populate (fills empty fields from selected CA project) 9 * - Warranty Expiry auto-calculation (Substantial Performance Date + Duration) 10 */ 11 12import { projectsApi } from '../api/projects.js'; 13import { confirm } from '../utils/modal.js'; 14import { SearchableDropdown } from '../components/SearchableDropdown.js'; 15import { DatePicker } from '../components/DatePicker.js'; 16import { setLoading } from '../utils/dom.js'; 17import { validateForm, clearError, clearAllErrors, resetForm } from '../utils/form-validation.js'; 18import { DraftManager } from '../utils/draft-manager.js'; 19 20 21// ======================================== 22// NUMERIC FIELD DESCRIPTOR - CA-136 23// ======================================== 24// 25// `#pc-budget` was `<input type="number" step="0.01" min="0">` until this card. Neither attribute 26// was enforcement (DESIGN_BIBLE.md section "Money and Number Inputs Validate in JS - Never in 27// HTML"); the app's ACTUAL enforcement was `app.js:107-135`, two document-level handlers gated on 28// the control's NATIVE numeric type, and they no longer fire on a text box. 29// 30// DERIVATION: `CreatePostConstructionRequest.ProjectBudget` and `UpdatePostConstructionRequest` 31// are both a NON-nullable `decimal`, and both paths write `becc_pm_projects_designer.Budget` - 32// `decimal(10,2)`, 8 integer digits and 2 decimals 33// (`../smartbuild.API/Repositories/SmrtBuild/Projects/ProjectRepository.cs:1582`). So 2 dp and a 34// REACHABLE ceiling of 99999999.99 that MySQL rejects past; `minimum: 0` is the template's own 35// authored `min="0"`. This is the SAME column and therefore the same rule as 36// `design-dev-create.js`'s `DESIGNER_BUDGET` (CA-132) and `deficiency-list-create.js`'s 37// `DL_NUMERICS.contractValue`. 38// 39// BLANK CONTRACT is `0`, DERIVED rather than inherited from the `|| 0` being replaced: the DTO 40// field is a non-nullable `decimal`, so there is no null to send. 41export const PC_PROJECT_BUDGET = { 42 selector: '#pc-budget', label: 'Project Budget', 43 numeric: { minimum: 0, maximum: 99999999.99, decimalPlaces: 2 }, 44}; 45 46/** 47 * The BOUNDED half of the post-construction numeric rules - range and precision. 48 * 49 * Well-formedness does NOT run here. It rides `data-validate="number"` on the control 50 * (`post-construction-create.html`), which puts it inside `validateForm`'s pass 2, so a missing 51 * required field and a malformed budget are marked and reported in ONE submit round rather than two 52 * (MCF-0425). Bounds cannot ride a data attribute: `data-validate` names a FORMAT from a fixed 53 * registry, and teaching it a per-field range would mean editing the shared util this card must not 54 * touch. 55 * 56 * @param {HTMLElement} root - the form container 57 * @returns {Array<Object>} rules for `validateForm`'s optional second argument (CA-146) 58 */ 59export function postConstructionNumericRules(root) { 60 const el = root?.querySelector(PC_PROJECT_BUDGET.selector); 61 return el 62 ? [{ el, label: PC_PROJECT_BUDGET.label, required: false, numeric: PC_PROJECT_BUDGET.numeric }] 63 : []; 64} 65 66/** 67 * The budget as a TRIMMED raw string - one reader for the mapping in both pages. 68 * 69 * It trims for the same reason `task-modal-factory.js`'s `readRawNumeric` does: `collectFormData` 70 * reads `el.value` untrimmed, `Number(' ')` is `0`, and `validateRequired` trims before it 71 * judges - so an untrimmed mapping would store a whole `0` for a box the gate had already accepted 72 * as EMPTY. Here both answers happen to be 0, so this is a contract being stated rather than a bug 73 * being fixed; stating it is what stops the next reader from "simplifying" it back. 74 * 75 * @param {Object} data - the output of `collectFormData()` 76 * @returns {string} the trimmed entry; `''` means blank 77 */ 78export function readPostConstructionBudget(data) { 79 return String(data?.projectBudget ?? '').trim(); 80} 81 82let abortController = null; 83let container = null; 84let contractAdminMap = {}; // projectId â CA data for auto-populate 85let draft = null; 86 87// ======================================== 88// PUBLIC API 89// ======================================== 90 91export function initPostConstructionCreatePage(addOptions) { 92 abortController = new AbortController(); 93 const signal = abortController.signal; 94 container = document.getElementById('pc-create-page'); 95 if (!container) return; 96 97 // Build CA lookup for auto-populate 98 buildContractAdminMap(addOptions.contractAdminProjects || []); 99 100 // Pre-fill suggested project number (before draft restore so draft takes priority) 101 const numberInput = container.querySelector('#pc-project-number'); 102 if (numberInput && !numberInput.value) { 103 numberInput.value = addOptions?.suggestedProjectNumber || ''; 104 } 105 106 // Setup draft persistence 107 draft = new DraftManager({ 108 key: 'sb_postconstruction_draft', 109 container, 110 statusSelector: '#pc-draft-status', 111 DatePicker, 112 collectFn: () => collectFormData(), 113 onRestore: () => updateLinkedNote(), 114 }); 115 116 // Restore draft (before wiring events so we don't trigger dirty on restore)
117 draft.restore(); 118 119 // Wire events 120 wireFormEvents(signal); 121 wireLinkedCA(signal); 122 wireWarrantyAutoCalc(signal); 123 wireBackButton(signal); 124 wireCancelButton(signal); 125 wireClearButton(signal); 126 wireSubmitButton(signal); 127 128 // Navigation guard 129 window.addEventListener('beforeunload', onBeforeUnload); 130} 131 132export function destroyPostConstructionCreatePage() { 133 // Clean up draft manager 134 draft?.destroy(); 135 draft = null; 136 137 // Remove navigation guard 138 window.removeEventListener('beforeunload', onBeforeUnload); 139 140 // Destroy components 141 if (container) { 142 SearchableDropdown.destroyAll(container); 143 } 144 145 // Abort listeners 146 abortController?.abort(); 147 abortController = null; 148 container = null; 149 contractAdminMap = {}; 150} 151 152// ======================================== 153// CONTRACT ADMIN LOOKUP 154// ======================================== 155 156function buildContractAdminMap(caProjects) { 157 contractAdminMap = {}; 158 for (const p of caProjects) { 159 contractAdminMap[p.projectId] = p; 160 } 161} 162 163// ======================================== 164// DRAFT PERSISTENCE 165// ======================================== 166 167function collectFormData() { 168 const data = {}; 169 if (!container) return data; 170 171 // Text/email/number inputs 172 container.querySelectorAll('input[name], textarea[name]').forEach(el => { 173 data[el.name] = el.value; 174 }); 175 176 // Selects (get actual value from original <select>, not the SD wrapper) 177 container.querySelectorAll('select[name]').forEach(el => { 178 data[el.name] = el.value; 179 }); 180 181 return data; 182} 183 184// Draft persistence â delegated to DraftManager 185function clearDraft() { draft?.clear(); } 186function scheduleDraftSave() { draft?.scheduleSave(); } 187 188// ======================================== 189// LINKED CONTRACT ADMIN AUTO-POPULATE 190// ======================================== 191 192function wireLinkedCA(signal) { 193 const select = container.querySelector('#pc-linked-ca'); 194 if (!select) return; 195 196 select.addEventListener('change', () => { 197 updateLinkedNote(); 198 autoPopulateFromCA(); 199 scheduleDraftSave(); 200 }, { signal }); 201} 202 203function updateLinkedNote() { 204 const select = container.querySelector('#pc-linked-ca'); 205 const note = container.querySelector('#pc-linked-note'); 206 if (!select || !note) return; 207 208 if (select.value) { 209 note.classList.remove('hidden'); 210 } else { 211 note.classList.add('hidden'); 212 } 213} 214 215/** 216 * Auto-populate empty fields from the selected Contract Admin project. 217 * Uses setIfEmpty() â only fills blank fields, preserves user overrides. 218 */ 219function autoPopulateFromCA() { 220 const select = container.querySelector('#pc-linked-ca'); 221 if (!select?.value) return; 222 223 const ca = contractAdminMap[select.value]; 224 if (!ca) return; 225 226 // Text fields 227 setIfEmpty('#pc-name', ca.projectTitle); 228 setIfEmpty('#pc-budget', ca.projectBudget > 0 ? ca.projectBudget : ''); 229 setIfEmpty('#pc-scope', ca.scopeOfWork); 230 231 // Date fields via DatePicker API 232 setDateIfEmpty('#pc-closeout-target', ca.submissionDueDate); 233 setDateIfEmpty('#pc-warranty-expiry', ca.anticipatedAwardDate); 234 setDateIfEmpty('#pc-poe-date', ca.anticipatedConstructionStart); 235 setDateIfEmpty('#pc-archive-deadline', ca.constructionCompletion); 236} 237 238function setIfEmpty(selector, value) { 239 if (!value) return; 240 const el = container.querySelector(selector); 241 if (!el || el.value?.trim()) return; // already has a value 242 el.value = value; 243} 244 245function setDateIfEmpty(selector, isoString) { 246 if (!isoString) return; 247 const el = container.querySelector(selector); 248 if (!el) return; 249 // DatePicker converts to hidden â check the hidden input's value 250 if (el.value?.trim()) return; // already has a date 251 const dp = DatePicker.getInstance(el); 252 if (dp) dp.setValue(isoString); 253} 254 255// ======================================== 256// WARRANTY EXPIRY AUTO-CALCULATION 257// ======================================== 258 259function wireWarrantyAutoCalc(signal) { 260 // Listen on the hidden input for substantial performance date (DatePicker converts it) 261 const substPerfEl = container.querySelector('#pc-subst-perf-date'); 262 const durationEl = container.querySelector('#pc-warranty-duration'); 263 264 if (substPerfEl) { 265 // DatePicker fires change on the hidden input when a date is picked 266 substPerfEl.addEventListener('change', () => recalcWarrantyExpiry(), { signal }); 267 } 268 269 if (durationEl) { 270 durationEl.addEventListener('change', () => recalcWarrantyExpiry(), { signal }); 271 } 272} 273 274function recalcWarrantyExpiry() { 275 const substPerfEl = container.querySelector('#pc-subst-perf-date'); 276 const durationEl = container.querySelector('#pc-warranty-duration'); 277 const autoLabel = container.querySelector('#pc-warranty-auto-label'); 278 279 const substPerf = substPerfEl?.value; 280 const duration = durationEl?.value; 281 282 // If "Custom" or no duration selected, allow manual entry 283 if (!duration || duration === 'Custom') { 284 if (autoLabel) autoLabel.classList.add('hidden'); 285 return; 286 } 287 288 if (!substPerf) { 289 if (autoLabel) autoLabel.classList.add('hidden'); 290 return; 291 } 292 293 // Parse years from duration string: "1 Year (Standard)" â 1, "2 Years" â 2, etc. 294 const yearsMatch = duration.match(/^(\d+)/); 295 if (!yearsMatch) { 296 if (autoLabel) autoLabel.classList.add('hidden'); 297 return; 298 } 299 300 const years = parseInt(yearsMatch[1]); 301 const perfDate = new Date(substPerf); 302 if (isNaN(perfDate.getTime())) { 303 if (autoLabel) autoLabel.classList.add('hidden'); 304 return; 305 } 306 307 // Add years 308 const expiryDate = new Date(perfDate); 309 expiryDate.setFullYear(expiryDate.getFullYear() + years); 310 311 // Set warranty expiry via DatePicker API 312 const warrantyEl = container.querySelector('#pc-warranty-expiry'); 313 if (warrantyEl) { 314 const dp = DatePicker.getInstance(warrantyEl); 315 if (dp) { 316 dp.setValue(expiryDate.toISOString().split('T')[0]); 317 } 318 } 319 320 // Show auto-calculated label 321 if (autoLabel) autoLabel.classList.remove('hidden'); 322 323 scheduleDraftSave(); 324} 325 326// ======================================== 327// EVENT WIRING 328// ======================================== 329 330function wireFormEvents(signal) { 331 // Auto-save on input/change + clear errors on input 332 container.addEventListener('input', (e) => { 333 scheduleDraftSave(); 334 if (e.target.hasAttribute('data-required')) clearError(container, e.target); 335 }, { signal }); 336 337 container.addEventListener('change', (e) => { 338 scheduleDraftSave(); 339 if (e.target.hasAttribute('data-required')) clearError(container, e.target); 340 }, { signal }); 341} 342 343function wireBackButton(signal) { 344 container.querySelector('#btn-pc-back')?.addEventListener('click', () => { 345 handleCancel(); 346 }, { signal }); 347} 348 349function wireCancelButton(signal) { 350 container.querySelector('#btn-pc-cancel')?.addEventListener('click', () => { 351 handleCancel(); 352 }, { signal }); 353} 354 355async function handleCancel() { 356 if (draft?.isDirty) { 357 const confirmed = await confirm({ 358 title: 'Discard unsaved changes?', 359 message: 'Your draft will be discarded. This cannot be undone.', 360 icon: 'trash-2', 361 confirmText: 'Discard', 362 confirmClass: 'btn-st-error', 363 // Page signal â the confirm auto-resolves false and closes on teardown, 364 // so it isn't orphaned over the next page. 365 signal: abortController?.signal, 366 }); 367 if (!confirmed) return; 368 clearDraft(); 369 } 370 // Navigate back to post-construction 371 page('/post-construction'); 372} 373 374function wireClearButton(signal) { 375 container.querySelector('#btn-pc-clear')?.addEventListener('click', () => handleClearForm(signal), { signal }); 376} 377 378async function handleClearForm(signal) { 379 const originController = abortController; 380 const originContainer = container; 381 const originDraft = draft; 382 const cleared = await resetForm(originContainer, originDraft?.key, { 383 DatePicker, 384 SearchableDropdown, 385 signal, 386 }); 387 if (!cleared) return; 388 if (signal?.aborted || abortController !== originController || container !== originContainer || draft !== originDraft) return; 389 originDraft?.cancelScheduledSave(); 390 if (originDraft) originDraft.isDirty = false;
391 originDraft?.showStatus('Form cleared'); 392} 393 394function wireSubmitButton(signal) { 395 const btn = container.querySelector('#btn-pc-submit'); 396 if (!btn) return; 397 398 btn.addEventListener('click', () => handleSubmit(btn), { signal }); 399} 400 401async function handleSubmit(btn) { 402 // Round one: required fields AND the budget's well-formedness, together 403 // (`data-validate="number"` in the template). Round two is the bounded half. Both run BEFORE 404 // the loading state, before the map and before the POST. 405 // CA-146 â ONE round: presence, format and the numeric rules are judged in a single pass. 406 if (!validateForm(container, postConstructionNumericRules(container))) return; 407 408 // Disable button 409 setLoading(btn, true, 'Creating...'); 410 411 try { 412 const data = collectFormData(); 413 const budgetRaw = readPostConstructionBudget(data); 414 415 const result = await projectsApi.createPostConstruction({ 416 projectTitle: data.projectTitle, 417 projectNumber: data.projectNumber || null, 418 clientReferenceNumber: data.clientReferenceNumber || null, 419 projectEmail: data.projectEmail, 420 systemTimeZoneID: data.systemTimeZoneID, 421 unitOfMeasurement: data.unitOfMeasurement, 422 // CA-136: exact `Number` after the gate, never a permissive parse before it. The old 423 // expression answered 0 for GARBAGE as well as for blank, so `12abc` was silently 424 // stored as a zero-dollar budget; garbage is refused before this line runs. The BLANK 425 // contract is unchanged and DERIVED: `CreatePostConstructionRequest.ProjectBudget` is a 426 // NON-nullable `decimal`, so an empty budget is 0 and never null. 427 projectBudget: budgetRaw === '' ? 0 : Number(budgetRaw), 428 scopeOfWork: data.scopeOfWork || null, 429 submissionDueDate: data.submissionDueDate || null, 430 anticipatedAwardDate: data.anticipatedAwardDate || null, 431 anticipatedConstructionStart: data.anticipatedConstructionStart || null, 432 constructionCompletion: data.constructionCompletion || null, 433 linkedContractAdminId: data.linkedContractAdminId ? parseInt(data.linkedContractAdminId) : null, 434 substantialPerformanceDate: data.substantialPerformanceDate || null, 435 warrantyDuration: data.warrantyDuration, 436 buildingOccupancyStatus: data.buildingOccupancyStatus, 437 postOccupancyEvalScope: data.postOccupancyEvalScope || null 438 }); 439 440 // Success â clear draft and navigate 441 if (draft) draft.isDirty = false; 442 clearDraft(); 443 window.showToast?.('Post-construction project created', 'success', { description: data.projectTitle }); 444 445 if (result.projectId) { 446 page(`/post-construction/${result.projectId}`); 447 } else { 448 page('/post-construction'); 449 } 450 } catch (error) { 451 if (error.name === 'AbortError') return; 452 window.showToast?.('Failed to create post-construction project', 'error', { description: error.message }); 453 setLoading(btn, false); 454 } 455} 456 457// ======================================== 458// NAVIGATION GUARD 459// ======================================== 460 461function onBeforeUnload(e) { 462 if (draft?.isDirty) { 463 // Save draft before leaving 464 draft.save(); 465 e.preventDefault(); 466 e.returnValue = ''; 467 } 468}
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.