1/** 2 * design-dev-edit.js â Full-page "Edit Design & Dev" form 3 * 4 * Reuses the design-dev-create.html template (with isEdit=true conditionals). 5 * Key differences from design-dev-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 (updateDesignDev) instead of POST (createDesignDev) 9 * - Navigation goes to /design-dev/{id} (not /design-dev) 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'; 19// CA-132: one rule for one column. The create twin owns the derivation comment. 20import { DESIGNER_BUDGET } from './design-dev-create.js'; 21import { populateEditForm } from '../utils/form-population.js'; 22import { DraftManager } from '../utils/draft-manager.js'; 23 24let abortController = null; 25let isDirty = false; 26let container = null; 27let currentId = null; 28let draft = null; 29 30// ======================================== 31// PUBLIC API 32// ======================================== 33 34export function initDesignDevEditPage(id, addOptions, data) { 35 abortController = new AbortController(); 36 const signal = abortController.signal; 37 container = document.getElementById('dd-create-page'); 38 currentId = id; 39 if (!container) return; 40 41 // Pre-populate form with existing data 42 populateForm(data, addOptions); 43 44 // Edit-backup: any in-flight edits from a prior session override the 45 // server values. Key is scoped per-record. Restore AFTER populateForm â 46 // draft wins so user edits aren't clobbered. 47 draft = new DraftManager({ 48 key: `sb_draft_design_dev_edit_${id}`, 49 container, 50 statusSelector: '#dd-draft-status', 51 DatePicker, 52 collectFn: () => collectFormData(), 53 onRestore: () => { 54 isDirty = true; // a restored backup IS dirty work 55 }, 56 }); 57 draft.restore(); 58 59 // Wire events 60 wireFormEvents(signal); 61 wireBackButton(signal); 62 wireCancelButton(signal); 63 wireSubmitButton(signal); 64 65 // Navigation guard 66 window.addEventListener('beforeunload', onBeforeUnload); 67} 68 69export function destroyDesignDevEditPage() { 70 window.removeEventListener('beforeunload', onBeforeUnload); 71 72 draft?.destroy(); 73 draft = null; 74 75 if (container) { 76 SearchableDropdown.destroyAll(container); 77 } 78 79 abortController?.abort(); 80 abortController = null; 81 container = null; 82 isDirty = false; 83 currentId = null; 84} 85 86// ======================================== 87// FORM PRE-POPULATION 88// ======================================== 89 90function populateForm(data, addOptions) { 91 if (!data || !container) return; 92 93 populateEditForm(container, data, { 94 dateFields: ['submissionDueDate', 'anticipatedAwardDate', 'anticipatedConstructionStart', 'constructionCompletion'], 95 currencyFields: ['projectBudget'], 96 }); 97} 98 99// ======================================== 100// FORM DATA COLLECTION 101// ======================================== 102 103function collectFormData() { 104 const data = {}; 105 if (!container) return data; 106 107 container.querySelectorAll('input[name], textarea[name]').forEach(el => { 108 data[el.name] = el.value; 109 }); 110 111 container.querySelectorAll('select[name]').forEach(el => { 112 data[el.name] = el.value; 113 }); 114 115 return data; 116} 117 118// ======================================== 119// EVENT WIRING 120// ======================================== 121 122function wireFormEvents(signal) { 123 container.addEventListener('input', (e) => { 124 isDirty = true; 125 draft?.scheduleSave(); 126 if (e.target.hasAttribute('data-required')) clearError(container, e.target); 127 }, { signal }); 128 129 container.addEventListener('change', (e) => { 130 isDirty = true; 131 draft?.scheduleSave(); 132 if (e.target.hasAttribute('data-required')) clearError(container, e.target); 133 }, { signal }); 134} 135 136function wireBackButton(signal) { 137 container.querySelector('#btn-dd-back')?.addEventListener('click', () => { 138 handleCancel(); 139 }, { signal }); 140} 141 142function wireCancelButton(signal) { 143 container.querySelector('#btn-dd-cancel')?.addEventListener('click', () => { 144 handleCancel(); 145 }, { signal }); 146} 147 148async function handleCancel() { 149 if (isDirty) { 150 const confirmed = await confirm({ 151 title: 'Discard unsaved changes?', 152 message: 'Any changes you made will be lost.', 153 icon: 'trash-2', 154 confirmText: 'Discard', 155 confirmClass: 'btn-st-error', 156 // Page signal â the confirm auto-resolves false and closes on teardown, 157 // so it isn't orphaned over the next page. 158 signal: abortController?.signal, 159 }); 160 if (!confirmed) return; 161 draft?.clear(); 162 } 163 page(`/design-dev/${currentId}`); 164} 165 166function wireSubmitButton(signal) { 167 const btn = container.querySelector('#btn-dd-submit'); 168 if (!btn) return; 169 170 btn.addEventListener('click', () => handleSubmit(btn), { signal }); 171} 172 173async function handleSubmit(btn) { 174 // CA-132 â before the loading state, before the map, before the PUT. The stored value cannot 175 // fail this rule: `becc_pm_projects_designer.Budget` is `decimal(10,2)`, so a reload of an
176 // untouched record is never blocked by it. 177 // CA-146 â the numeric rule rides `validateForm`'s own pass, so a missing required field and an 178 // unreadable budget are marked, summarised and announced in ONE submit round. 179 if (!validateForm(container, [ 180 { el: container.querySelector('#dd-budget'), label: 'Project Budget', required: false, numeric: DESIGNER_BUDGET }, 181 ])) return; 182 183 setLoading(btn, true, 'Saving...'); 184 185 try { 186 const data = collectFormData(); 187 188 await projectsApi.updateDesignDev(currentId, { 189 projectTitle: data.projectTitle, 190 projectNumber: data.projectNumber || null, 191 clientReferenceNumber: data.clientReferenceNumber || null, 192 projectEmail: data.projectEmail, 193 // CA-132: exact `Number`, not `parseFloat`. Blank contract unchanged and stated â 194 // `UpdateDesignDevRequest.ProjectBudget` is a NON-nullable `decimal`, so a blank box 195 // has always meant 0 here. 196 projectBudget: data.projectBudget ? Number(data.projectBudget) : 0, 197 scopeOfWork: data.scopeOfWork || null, 198 submissionDueDate: data.submissionDueDate || null, 199 anticipatedAwardDate: data.anticipatedAwardDate || null, 200 anticipatedConstructionStart: data.anticipatedConstructionStart || null, 201 constructionCompletion: data.constructionCompletion || null, 202 linkedProposalId: data.linkedProposalId ? parseInt(data.linkedProposalId) : null, 203 currentPhase: data.currentPhase || null, 204 deliveryMethod: data.deliveryMethod || null, 205 buildingClassification: data.buildingClassification || null, 206 constructionType: data.constructionType || null 207 }); 208 209 isDirty = false; 210 draft?.clear(); 211 window.showToast?.('Design & Dev updated', 'success', { description: data.projectTitle }); 212 page(`/design-dev/${currentId}`); 213 } catch (error) { 214 if (error.name === 'AbortError') return; 215 window.showToast?.('Failed to update project', 'error', { description: error.message }); 216 setLoading(btn, false); 217 } 218} 219 220// ======================================== 221// NAVIGATION GUARD 222// ======================================== 223 224function onBeforeUnload(e) { 225 if (isDirty) { 226 draft?.save(); // synchronous localStorage write â survives browser close 227 e.preventDefault(); 228 e.returnValue = ''; 229 } 230}
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.