1/** 2 * project-pursuit-create.js â Full-page "New Pursuit" form 3 * 4 * Draft persistence (localStorage), Design Bible validation, 5 * navigation guard (beforeunload). 6 */ 7 8import { pursuitApi } from '../api/project-pursuits.js'; 9import { todayISO } from '../utils/time-utils.js'; 10import { confirm } from '../utils/modal.js'; 11import { SearchableDropdown } from '../components/SearchableDropdown.js'; 12import { DatePicker } from '../components/DatePicker.js'; 13import { setLoading } from '../utils/dom.js'; 14import { validateForm, clearError, resetForm } from '../utils/form-validation.js'; 15import { DraftManager } from '../utils/draft-manager.js'; 16 17let abortController = null; 18let container = null; 19let draft = null; 20 21// ââ Numeric field descriptor â CA-133 âââââââââââââââââââââââââââââââââââââââ 22// `#pp-budget` (`project-pursuit-create.html:142`) was `<input type="number" step="0.01" min="0">` 23// until this card. Those attributes were affordances, never enforcement (DESIGN_BIBLE.md § "Money 24// and Number Inputs Validate in JS â Never in HTML"); the app's real numeric enforcement was 25// `app.js:107-135`, whose two document-level handlers are gated on the control's native numeric 26// type and therefore no longer fire on this box. Constraint DERIVED from 27// `CreatePursuitRequest.EstimatedBudget` (`decimal?`) and the stored column 28// `becc_pm_projecttasks_pursuit.EstimatedBudget` (`decimal(15,2)` NULL): two decimal places, 29// nullable, no ceiling worth stating at 10^13. 30const PURSUIT_BUDGET = { minimum: 0, decimalPlaces: 2 }; 31 32// ======================================== 33// PUBLIC API 34// ======================================== 35 36export function initProjectPursuitCreatePage(addOptions) { 37 abortController = new AbortController(); 38 const signal = abortController.signal; 39 container = document.getElementById('pursuit-create-page'); 40 if (!container) return; 41 42 // Set default date to today for Date Identified 43 applyDefaultDateIdentified(); 44 45 // Pre-fill suggested project number (before draft restore so draft takes priority) 46 const numberInput = container.querySelector('#pp-number'); 47 if (numberInput && !numberInput.value) { 48 numberInput.value = addOptions?.suggestedProjectNumber || ''; 49 } 50 51 // Setup draft persistence 52 draft = new DraftManager({ 53 key: 'sb_pursuit_draft', 54 container, 55 statusSelector: '#pp-draft-status', 56 DatePicker, 57 collectFn: () => collectFormData(), 58 }); 59 60 // Restore draft (before wiring events so we don't trigger dirty on restore) 61 draft.restore(); 62 63 // Wire events 64 wireFormEvents(signal); 65 wireBackButton(signal); 66 wireCancelButton(signal); 67 wireClearButton(signal); 68 wireSubmitButton(signal); 69 70 // Navigation guard 71 window.addEventListener('beforeunload', onBeforeUnload); 72} 73 74export function destroyProjectPursuitCreatePage() { 75 // Clean up draft manager 76 draft?.destroy(); 77 draft = null; 78 79 window.removeEventListener('beforeunload', onBeforeUnload); 80 81 if (container) { 82 SearchableDropdown.destroyAll(container); 83 } 84 85 abortController?.abort(); 86 abortController = null; 87 container = null; 88} 89 90// ======================================== 91// DRAFT PERSISTENCE 92// ======================================== 93 94function collectFormData() { 95 const data = {}; 96 if (!container) return data; 97 98 container.querySelectorAll('input[name], textarea[name]').forEach(el => { 99 data[el.name] = el.value; 100 }); 101 102 container.querySelectorAll('select[name]').forEach(el => { 103 data[el.name] = el.value; 104 }); 105 106 return data; 107} 108 109// Draft persistence â delegated to DraftManager 110function clearDraft() { draft?.clear(); } 111function scheduleDraftSave() { draft?.scheduleSave(); } 112 113// ======================================== 114// EVENT WIRING 115// ======================================== 116 117function wireFormEvents(signal) { 118 container.addEventListener('input', (e) => { 119 scheduleDraftSave(); 120 if (e.target.hasAttribute('data-required')) clearError(container, e.target); 121 }, { signal }); 122 123 container.addEventListener('change', (e) => { 124 scheduleDraftSave(); 125 if (e.target.hasAttribute('data-required')) clearError(container, e.target); 126 }, { signal }); 127 128 // Lead Source â conditional "Referred By" field 129 const leadSourceEl = container.querySelector('#pp-lead-source'); 130 const referredByRow = container.querySelector('#pp-referred-by-row'); 131 if (leadSourceEl && referredByRow) { 132 const handleSourceChange = () => { 133 const sd = SearchableDropdown.getInstance(leadSourceEl); 134 const val = sd ? sd.getValue() : leadSourceEl.value; 135 referredByRow.classList.toggle('hidden', val !== 'Referral'); 136 }; 137 leadSourceEl.addEventListener('change', handleSourceChange, { signal }); 138 // Check initial state (for draft restore) 139 handleSourceChange(); 140 } 141} 142 143function wireBackButton(signal) { 144 container.querySelector('#btn-pursuit-back')?.addEventListener('click', () => { 145 handleCancel(); 146 }, { signal }); 147} 148 149function wireCancelButton(signal) { 150 container.querySelector('#btn-pursuit-cancel')?.addEventListener('click', () => { 151 handleCancel(); 152 }, { signal }); 153} 154 155async function handleCancel() { 156 if (draft?.isDirty) { 157 const confirmed = await confirm({ 158 title: 'Discard unsaved changes?', 159 message: 'Your draft will be discarded. This cannot be undone.', 160 icon: 'trash-2', 161 confirmText: 'Discard', 162 confirmClass: 'btn-st-error', 163 // Page signal â the confirm auto-resolves false and closes on teardown, 164 // so it isn't orphaned over the next page. 165 signal: abortController?.signal, 166 }); 167 if (!confirmed) return; 168 clearDraft(); 169 } 170 page('/project-pursuit'); 171} 172 173/** 174 * Date Identified defaults to today (the page's declared default). Applied at init and again 175 * after a Reset (resetForm's onAfterReset) so "reset to their defaults" keeps its promise â CA-102. 176 */ 177function applyDefaultDateIdentified(pageContainer = container) { 178 const dateIdField = pageContainer.querySelector('#pp-date-identified'); 179 if (!dateIdField) return; 180 const dp = DatePicker.getInstance(dateIdField); 181 if (dp && !dp.getValue()) { 182 dp.setValue(todayISO()); 183 } 184} 185 186function wireClearButton(signal) { 187 container.querySelector('#btn-pursuit-clear')?.addEventListener('click', () => handleClearForm(signal), { signal }); 188} 189 190async function handleClearForm(signal) { 191 const originController = abortController; 192 const originContainer = container; 193 const originDraft = draft; 194 const cleared = await resetForm(originContainer, originDraft?.key, { 195 DatePicker, 196 SearchableDropdown, 197 signal, 198 onAfterReset: () => applyDefaultDateIdentified(originContainer) 199 }); 200 if (!cleared) return; 201 if (signal?.aborted || abortController !== originController || container !== originContainer || draft !== originDraft) return; 202 originDraft?.cancelScheduledSave(); 203 if (originDraft) originDraft.isDirty = false;
204 originDraft?.showStatus('Form cleared'); 205} 206 207function wireSubmitButton(signal) { 208 const btn = container.querySelector('#btn-pursuit-submit'); 209 if (!btn) return; 210 211 btn.addEventListener('click', () => handleSubmit(btn), { signal }); 212} 213 214async function handleSubmit(btn) { 215 // CA-133: `#pp-budget` is a text input now, so this rule is the only thing between `$500` / 216 // `1,000` / `12abc` and the POST. Before the loading state and before the map. 217 // CA-146 â the rule rides `validateForm`'s own pass, so a missing required field and an 218 // unreadable budget are marked, summarised and announced in ONE submit round. 219 if (!validateForm(container, [ 220 { el: container.querySelector('#pp-budget'), label: 'Estimated Budget', required: false, numeric: PURSUIT_BUDGET }, 221 ])) return; 222 223 setLoading(btn, true, 'Creating...'); 224 225 try { 226 const data = collectFormData(); 227 228 const result = await pursuitApi.create({ 229 pursuitName: data.pursuitName, 230 leadSource: data.leadSource, 231 clientName: data.clientName, 232 contactName: data.contactName || null, 233 contactEmail: data.contactEmail || null, 234 contactPhone: data.contactPhone || null, 235 dateIdentified: data.dateIdentified || null, 236 projectType: data.projectType || null, 237 // CA-133: exact `Number`, not `parseFloat`. Blank contract unchanged â 238 // `CreatePursuitRequest.EstimatedBudget` is `decimal?`, so blank is `null`, never 0. 239 estimatedBudget: data.estimatedBudget ? Number(data.estimatedBudget) : null, 240 city: data.city || null, 241 province: data.province || null, 242 assignedPrincipalId: data.assignedPrincipalId ? parseInt(data.assignedPrincipalId) : null, 243 notes: data.notes || null, 244 referredBy: data.referredBy || null, 245 projectNumber: data.projectNumber || null, 246 }); 247 248 // Success â clear draft and navigate 249 if (draft) draft.isDirty = false; 250 clearDraft(); 251 window.showToast?.('Pursuit created', 'success', { description: `${data.pursuitName} (${result?.internalRef || ''})` }); 252 253 if (result?.pursuitId) { 254 page(`/project-pursuit/${result.pursuitId}`); 255 } else { 256 page('/project-pursuit'); 257 } 258 } catch (error) { 259 if (error.name === 'AbortError') return; 260 window.showToast?.('Failed to create pursuit', 'error', { description: error.message }); 261 setLoading(btn, false); 262 } 263} 264 265// ======================================== 266// NAVIGATION GUARD 267// ======================================== 268 269function onBeforeUnload(e) { 270 if (draft?.isDirty) { 271 draft.save(); 272 e.preventDefault(); 273 e.returnValue = ''; 274 } 275}
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.