1/** 2 * design-dev-create.js â Full-page "New Design & Dev" form 3 * 4 * Replaces the old modal-based create flow. Features: 5 * - Draft persistence (localStorage auto-save) 6 * - Design Bible validation (red borders, shake, scroll-to-first) 7 * - Navigation guard (beforeunload) 8 * - Linked Proposal auto-populate (fills empty fields from selected proposal) 9 */ 10 11import { projectsApi } from '../api/projects.js'; 12import { confirm } from '../utils/modal.js'; 13import { SearchableDropdown } from '../components/SearchableDropdown.js'; 14import { DatePicker } from '../components/DatePicker.js'; 15import { setLoading } from '../utils/dom.js'; 16import { validateForm, clearError, clearAllErrors, resetForm } from '../utils/form-validation.js'; 17 18/** 19 * The Project Budget rule â CA-132, shared with `design-dev-edit.js`. 20 * 21 * `#dd-budget` was `<input type="number" min="0" step="0.01">` until this card; `min`/`step` were 22 * never enforcement and `app.js:107-135` (which was) no longer fires on a text box. 23 * 24 * DERIVATION: both the create and the edit path write `becc_pm_projects_designer.Budget`, which is 25 * `decimal(10,2)` â 8 integer digits and 2 decimals â behind a NON-nullable `decimal` DTO. So 2 dp, 26 * a real ceiling of 99999999.99 that MySQL rejects past (the rule turns that 500 into a sentence), 27 * and `minimum: 0` from the template's own authored `min="0"`. 28 */ 29export const DESIGNER_BUDGET = { minimum: 0, maximum: 99999999.99, decimalPlaces: 2 }; 30import { DraftManager } from '../utils/draft-manager.js'; 31 32let abortController = null; 33let container = null; 34let proposalsMap = {}; // projectId â proposal data for auto-populate 35let draft = null; 36 37// ======================================== 38// PUBLIC API 39// ======================================== 40 41export function initDesignDevCreatePage(addOptions) { 42 abortController = new AbortController(); 43 const signal = abortController.signal; 44 container = document.getElementById('dd-create-page'); 45 if (!container) return; 46 47 // Build proposals lookup for auto-populate 48 buildProposalsMap(addOptions.proposals || []); 49 50 // Pre-fill suggested project number (before draft restore so draft takes priority) 51 const numberInput = container.querySelector('#dd-number'); 52 if (numberInput && !numberInput.value) { 53 numberInput.value = addOptions?.suggestedProjectNumber || ''; 54 } 55 56 // Setup draft persistence 57 draft = new DraftManager({ 58 key: 'sb_designdev_draft', 59 container, 60 statusSelector: '#dd-draft-status', 61 DatePicker, 62 collectFn: () => collectFormData(), 63 onRestore: () => updateLinkedNote(), 64 }); 65 66 // Restore draft (before wiring events so we don't trigger dirty on restore) 67 draft.restore(); 68 69 // Wire events 70 wireFormEvents(signal); 71 wireLinkedProposal(signal); 72 wireBackButton(signal); 73 wireCancelButton(signal); 74 wireClearButton(signal); 75 wireSubmitButton(signal); 76 77 // Navigation guard 78 window.addEventListener('beforeunload', onBeforeUnload); 79} 80 81export function destroyDesignDevCreatePage() { 82 // Clean up draft manager 83 draft?.destroy(); 84 draft = null; 85 86 // Remove navigation guard 87 window.removeEventListener('beforeunload', onBeforeUnload); 88 89 // Destroy components 90 if (container) { 91 SearchableDropdown.destroyAll(container); 92 } 93 94 // Abort listeners 95 abortController?.abort(); 96 abortController = null; 97 container = null; 98 proposalsMap = {}; 99} 100 101// ======================================== 102// PROPOSALS LOOKUP 103// ======================================== 104 105function buildProposalsMap(proposals) { 106 proposalsMap = {}; 107 for (const p of proposals) { 108 proposalsMap[p.projectId] = p; 109 } 110} 111 112// ======================================== 113// DRAFT PERSISTENCE 114// ======================================== 115 116function collectFormData() { 117 const data = {}; 118 if (!container) return data; 119 120 // Text/email/number inputs 121 container.querySelectorAll('input[name], textarea[name]').forEach(el => { 122 data[el.name] = el.value; 123 }); 124 125 // Selects (get actual value from original <select>, not the SD wrapper) 126 container.querySelectorAll('select[name]').forEach(el => { 127 data[el.name] = el.value; 128 }); 129 130 return data; 131} 132 133// Draft persistence â delegated to DraftManager 134function clearDraft() { draft?.clear(); } 135function scheduleDraftSave() { draft?.scheduleSave(); } 136 137// ======================================== 138// LINKED PROPOSAL AUTO-POPULATE 139// ======================================== 140 141function wireLinkedProposal(signal) { 142 const select = container.querySelector('#dd-linked-proposal'); 143 if (!select) return; 144 145 select.addEventListener('change', () => { 146 updateLinkedNote(); 147 autoPopulateFromProposal(); 148 scheduleDraftSave(); 149 }, { signal }); 150} 151 152function updateLinkedNote() { 153 const select = container.querySelector('#dd-linked-proposal'); 154 const note = container.querySelector('#dd-linked-note'); 155 if (!select || !note) return; 156 157 if (select.value) { 158 note.classList.remove('hidden'); 159 } else { 160 note.classList.add('hidden'); 161 } 162} 163 164/** 165 * Auto-populate empty fields from the selected proposal. 166 * Uses setIfEmpty() â only fills blank fields, preserves user overrides. 167 */ 168function autoPopulateFromProposal() { 169 const select = container.querySelector('#dd-linked-proposal'); 170 if (!select?.value) return; 171 172 const proposal = proposalsMap[select.value]; 173 if (!proposal) return; 174 175 // Text fields 176 setIfEmpty('#dd-name', proposal.projectTitle); 177 setIfEmpty('#dd-client-ref', proposal.clientReferenceNumber); 178 setIfEmpty('#dd-budget', proposal.projectBudget > 0 ? proposal.projectBudget : ''); 179 setIfEmpty('#dd-scope', proposal.scopeOfWork); 180 181 // Select fields â only set if current value is empty 182 setSelectIfEmpty('#dd-building-class', proposal.buildingClassification); 183 184 // Date fields via DatePicker API 185 setDateIfEmpty('#dd-submission-due', proposal.submissionDueDate); 186 setDateIfEmpty('#dd-award-date', proposal.anticipatedAwardDate); 187 setDateIfEmpty('#dd-con-start', proposal.anticipatedConstructionStart); 188 setDateIfEmpty('#dd-con-completion', proposal.constructionCompletion); 189} 190 191function setIfEmpty(selector, value) { 192 if (!value) return; 193 const el = container.querySelector(selector); 194 if (!el || el.value?.trim()) return; // already has a value 195 el.value = value; 196} 197 198function setSelectIfEmpty(selector, value) { 199 if (!value) return; 200 const el = container.querySelector(selector); 201 if (!el || el.value) return; // already selected 202 el.value = value; 203 el.dispatchEvent(new Event('change', { bubbles: true })); 204} 205 206function setDateIfEmpty(selector, isoString) { 207 if (!isoString) return; 208 const el = container.querySelector(selector); 209 if (!el) return; 210 // DatePicker converts to hidden â check the hidden input's value 211 if (el.value?.trim()) return; // already has a date 212 const dp = DatePicker.getInstance(el); 213 if (dp) dp.setValue(isoString); 214} 215 216// ======================================== 217// EVENT WIRING 218// ======================================== 219 220function wireFormEvents(signal) { 221 // Auto-save on input/change + clear errors on input 222 container.addEventListener('input', (e) => { 223 scheduleDraftSave(); 224 if (e.target.hasAttribute('data-required')) clearError(container, e.target); 225 }, { signal }); 226 227 container.addEventListener('change', (e) => { 228 scheduleDraftSave(); 229 if (e.target.hasAttribute('data-required')) clearError(container, e.target); 230 }, { signal }); 231} 232 233function wireBackButton(signal) { 234 container.querySelector('#btn-dd-back')?.addEventListener('click', () => { 235 handleCancel(); 236 }, { signal }); 237} 238 239function wireCancelButton(signal) { 240 container.querySelector('#btn-dd-cancel')?.addEventListener('click', () => { 241 handleCancel(); 242 }, { signal }); 243} 244 245async function handleCancel() { 246 if (draft?.isDirty) { 247 const confirmed = await confirm({ 248 title: 'Discard unsaved changes?', 249 message: 'Your draft will be discarded. This cannot be undone.', 250 icon: 'trash-2', 251 confirmText: 'Discard', 252 confirmClass: 'btn-st-error', 253 // Page signal â the confirm auto-resolves false and closes on teardown, 254 // so it isn't orphaned over the next page. 255 signal: abortController?.signal, 256 }); 257 if (!confirmed) return; 258 clearDraft(); 259 } 260 // Navigate back to design & dev list 261 page('/design-dev'); 262} 263 264function wireClearButton(signal) { 265 container.querySelector('#btn-dd-clear')?.addEventListener('click', () => handleClearForm(signal), { signal }); 266} 267 268async function handleClearForm(signal) { 269 const originController = abortController; 270 const originContainer = container; 271 const originDraft = draft; 272 const cleared = await resetForm(originContainer, originDraft?.key, { 273 DatePicker, 274 SearchableDropdown, 275 signal, 276 }); 277 if (!cleared) return; 278 if (signal?.aborted || abortController !== originController || container !== originContainer || draft !== originDraft) return; 279 originDraft?.cancelScheduledSave(); 280 if (originDraft) originDraft.isDirty = false;
281 originDraft?.showStatus('Form cleared'); 282} 283 284function wireSubmitButton(signal) { 285 const btn = container.querySelector('#btn-dd-submit'); 286 if (!btn) return; 287 288 btn.addEventListener('click', () => handleSubmit(btn), { signal }); 289} 290 291async function handleSubmit(btn) { 292 // CA-132 â before the loading state, before the map, before the POST. 293 // CA-146 â the numeric rule rides `validateForm`'s own pass, so a missing required field and an 294 // unreadable budget are marked, summarised and announced in ONE submit round. 295 if (!validateForm(container, [ 296 { el: container.querySelector('#dd-budget'), label: 'Project Budget', required: false, numeric: DESIGNER_BUDGET }, 297 ])) return; 298 299 // Disable button 300 setLoading(btn, true, 'Creating...'); 301 302 try { 303 const data = collectFormData(); 304 305 const result = await projectsApi.createDesignDev({ 306 projectTitle: data.projectTitle, 307 projectNumber: data.projectNumber || null, 308 clientReferenceNumber: data.clientReferenceNumber || null, 309 projectEmail: data.projectEmail, 310 systemTimeZoneID: data.systemTimeZoneID, 311 unitOfMeasurement: data.unitOfMeasurement, 312 // CA-132: exact `Number`, not `parseFloat`. Blank contract unchanged and stated â 313 // `CreateDesignDevRequest.ProjectBudget` is a NON-nullable `decimal`, so a blank box 314 // has always meant 0 here. 315 projectBudget: data.projectBudget ? Number(data.projectBudget) : 0, 316 scopeOfWork: data.scopeOfWork || null, 317 submissionDueDate: data.submissionDueDate || null, 318 anticipatedConstructionStart: data.anticipatedConstructionStart || null, 319 constructionCompletion: data.constructionCompletion || null, 320 anticipatedAwardDate: data.anticipatedAwardDate || null, 321 linkedProposalId: data.linkedProposalId ? parseInt(data.linkedProposalId) : null, 322 deliveryMethod: data.deliveryMethod, 323 buildingClassification: data.buildingClassification || null, 324 constructionType: data.constructionType || null, 325 currentPhase: data.currentPhase 326 }); 327 328 // Success â clear draft and navigate 329 if (draft) draft.isDirty = false; 330 clearDraft(); 331 window.showToast?.('Design & Dev project created', 'success', { description: data.projectTitle }); 332 333 if (result.projectId) { 334 page(`/design-dev/${result.projectId}`); 335 } else { 336 page('/design-dev'); 337 } 338 } catch (error) { 339 if (error.name === 'AbortError') return; 340 window.showToast?.('Failed to create project', 'error', { description: error.message }); 341 setLoading(btn, false); 342 } 343} 344 345// ======================================== 346// NAVIGATION GUARD 347// ======================================== 348 349function onBeforeUnload(e) { 350 if (draft?.isDirty) { 351 // Save draft before leaving 352 draft.save(); 353 e.preventDefault(); 354 e.returnValue = ''; 355 } 356}
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.