1/** 2 * bids-tendering-edit.js â Full-page "Edit Bids & Tendering" form 3 * 4 * Reuses the bids-tendering-create.html template (with isEdit=true conditionals). 5 * Key differences from bids-tendering-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 (updateBidsTendering) instead of POST (createBidsTendering) 9 * - Navigation goes to /bids-tendering/{id} (not /bids-tendering) 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-130 â both pages render `bids-tendering-create.html`, so the numeric rule set lives in the 20// create module and is imported here rather than copied. Same shape CA-131 used for 21// `ownsNumericRule`; the create module has no top-level side effects. 22import { bidsTenderingNumericRules, ownsBidsTenderingNumericRule } from './bids-tendering-create.js'; 23import { populateEditForm } from '../utils/form-population.js'; 24import { DraftManager } from '../utils/draft-manager.js'; 25 26let abortController = null; 27let isDirty = false; 28let container = null; 29let currentId = null; 30let draft = null; 31 32// ======================================== 33// PUBLIC API 34// ======================================== 35 36export function initBidsTenderingEditPage(id, addOptions, data) { 37 abortController = new AbortController(); 38 const signal = abortController.signal; 39 container = document.getElementById('bt-create-page'); 40 currentId = id; 41 if (!container) return; 42 43 // Pre-populate form with existing data 44 populateForm(data, addOptions); 45 46 // Edit-backup: any in-flight edits from a prior session override the 47 // server values. Key is scoped per-record. Restore AFTER populateForm â 48 // draft wins so user edits aren't clobbered. 49 draft = new DraftManager({ 50 key: `sb_draft_bids_tendering_edit_${id}`, 51 container, 52 statusSelector: '#bt-draft-status', 53 DatePicker, 54 collectFn: () => collectFormData(), 55 onRestore: (data) => { 56 if (data.preBidMeetingRequired) { 57 const hidden = container.querySelector('#bt-pre-bid-meeting'); 58 if (hidden) hidden.value = data.preBidMeetingRequired; 59 updatePreBidMeetingUI(data.preBidMeetingRequired); 60 } 61 isDirty = true; // a restored backup IS dirty work 62 }, 63 }); 64 draft.restore(); 65 66 // Wire events 67 wireFormEvents(signal); 68 wirePreBidMeetingGroup(signal); 69 wireBackButton(signal); 70 wireCancelButton(signal); 71 wireSubmitButton(signal); 72 73 // Navigation guard 74 window.addEventListener('beforeunload', onBeforeUnload); 75} 76 77export function destroyBidsTenderingEditPage() { 78 window.removeEventListener('beforeunload', onBeforeUnload); 79 80 draft?.destroy(); 81 draft = null; 82 83 if (container) { 84 SearchableDropdown.destroyAll(container); 85 } 86 87 abortController?.abort(); 88 abortController = null; 89 container = null; 90 isDirty = false; 91 currentId = null; 92} 93 94// ======================================== 95// FORM PRE-POPULATION 96// ======================================== 97 98function populateForm(data, addOptions) { 99 if (!data || !container) return; 100 101 populateEditForm(container, data, { 102 dateFields: ['tenderDue', 'anticipatedAwardDate', 'constructionStartDate', 'constructionCompletion'], 103 currencyFields: ['constructionEstimate'], 104 }); 105 106 // Pre-bid meeting segmented button 107 if (data.preBidMeetingRequired) { 108 const hidden = container.querySelector('#bt-pre-bid-meeting'); 109 if (hidden) hidden.value = data.preBidMeetingRequired; 110 updatePreBidMeetingUI(data.preBidMeetingRequired); 111 } 112} 113 114// ======================================== 115// FORM DATA COLLECTION 116// ======================================== 117 118function collectFormData() { 119 const data = {}; 120 if (!container) return data; 121 122 container.querySelectorAll('input[name], textarea[name]').forEach(el => { 123 data[el.name] = el.value; 124 }); 125 126 container.querySelectorAll('select[name]').forEach(el => { 127 data[el.name] = el.value; 128 }); 129 130 const hidden = container.querySelector('#bt-pre-bid-meeting'); 131 if (hidden) data.preBidMeetingRequired = hidden.value; 132 133 return data; 134} 135 136// ======================================== 137// EVENT WIRING 138// ======================================== 139 140function wireFormEvents(signal) { 141 container.addEventListener('input', (e) => { 142 isDirty = true; 143 draft?.scheduleSave(); 144 if (e.target.hasAttribute('data-required') && !ownsBidsTenderingNumericRule(e.target)) clearError(container, e.target); 145 }, { signal }); 146 147 container.addEventListener('change', (e) => { 148 isDirty = true; 149 draft?.scheduleSave(); 150 if (e.target.hasAttribute('data-required') && !ownsBidsTenderingNumericRule(e.target)) clearError(container, e.target); 151 }, { signal }); 152} 153 154function wirePreBidMeetingGroup(signal) { 155 container.querySelectorAll('.prebid-seg-btn').forEach(btn => { 156 btn.addEventListener('click', () => { 157 const value = btn.dataset.value; 158 const hidden = container.querySelector('#bt-pre-bid-meeting'); 159 if (hidden) hidden.value = value; 160 updatePreBidMeetingUI(value); 161 isDirty = true; 162 draft?.scheduleSave(); 163 }, { signal }); 164 }); 165} 166 167function updatePreBidMeetingUI(activeValue) { 168 container.querySelectorAll('.prebid-seg-btn').forEach(btn => { 169 btn.classList.toggle('active', btn.dataset.value === activeValue); 170 }); 171} 172 173function wireBackButton(signal) { 174 container.querySelector('#btn-bt-back')?.addEventListener('click', () => { 175 handleCancel(); 176 }, { signal }); 177} 178 179function wireCancelButton(signal) { 180 container.querySelector('#btn-bt-cancel')?.addEventListener('click', () => { 181 handleCancel(); 182 }, { signal }); 183} 184 185async function handleCancel() { 186 if (isDirty) { 187 const confirmed = await confirm({ 188 title: 'Discard unsaved changes?', 189 message: 'Any changes you made will be lost.', 190 icon: 'trash-2', 191 confirmText: 'Discard', 192 confirmClass: 'btn-st-error', 193 // Page signal â the confirm auto-resolves false and closes on teardown, 194 // so it isn't orphaned over the next page. 195 signal: abortController?.signal, 196 }); 197 if (!confirmed) return; 198 draft?.clear(); 199 } 200 page(`/bids-tendering/${currentId}`); 201} 202 203function wireSubmitButton(signal) { 204 const btn = container.querySelector('#btn-bt-submit'); 205 if (!btn) return; 206 207 btn.addEventListener('click', () => handleSubmit(btn), { signal }); 208} 209 210async function handleSubmit(btn) { 211 // CA-130 â before the loading state and before any mapping; see the create module's note. 212 // CA-146 â one call, one round: presence, format and the numeric rules together. 213 if (!validateForm(container, bidsTenderingNumericRules(container))) return; 214
215 setLoading(btn, true, 'Saving...'); 216 217 try { 218 const data = collectFormData(); 219 220 await projectsApi.updateBidsTendering(currentId, { 221 projectTitle: data.projectTitle, 222 projectNumber: data.projectNumber || null, 223 clientReferenceNumber: data.clientReferenceNumber || null, 224 projectEmail: data.projectEmail, 225 // Exact `Number` semantics after the gate (intake step 6); `ConstructionEstimate` is a 226 // non-nullable `decimal` and the control is `data-required`, so the 0 branch is now 227 // unreachable rather than a silent fallback for an unreadable entry. 228 constructionEstimate: data.constructionEstimate?.trim() ? Number(data.constructionEstimate) : 0, 229 scopeOfWork: data.scopeOfWork || null, 230 tenderDue: data.tenderDue || null, 231 anticipatedAwardDate: data.anticipatedAwardDate || null, 232 constructionStartDate: data.constructionStartDate || null, 233 constructionCompletion: data.constructionCompletion || null, 234 linkedDesignDevId: data.linkedDesignDevId ? parseInt(data.linkedDesignDevId) : null, 235 biddingMethod: data.biddingMethod || null, 236 contractType: data.contractType || null, 237 numberOfBidSets: data.numberOfBidSets?.trim() ? Number(data.numberOfBidSets) : null, 238 preBidMeetingRequired: data.preBidMeetingRequired || null 239 }); 240 241 isDirty = false; 242 draft?.clear(); 243 window.showToast?.('Bid & tendering updated', 'success', { description: data.projectTitle }); 244 page(`/bids-tendering/${currentId}`); 245 } catch (error) { 246 if (error.name === 'AbortError') return; 247 window.showToast?.('Failed to update project', 'error', { description: error.message }); 248 setLoading(btn, false); 249 } 250} 251 252// ======================================== 253// NAVIGATION GUARD 254// ======================================== 255 256function onBeforeUnload(e) { 257 if (isDirty) { 258 draft?.save(); // synchronous localStorage write â survives browser close 259 e.preventDefault(); 260 e.returnValue = ''; 261 } 262}
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.