1/** 2 * tenders-create.js â Full-page "New Tender" / "Edit Tender" form. 3 * 4 * One module, two modes: 5 * ⢠CREATE (existingTender = null) â draft persistence (localStorage auto-save/restore). 6 * ⢠EDIT (existingTender set) â prefilled from the record; no draft. 7 * 8 * Follows the full-page create-form gold standard (Proposals): sticky header/footer, 9 * shared validation (utils/form-validation.js), render()'s afterRender auto-enhances 10 * SearchableDropdown + DatePicker (no manual enhanceAll). Backend: smrtbuild/tenders. 11 */ 12 13import { tendersApi } from '../api/tenders.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, resetForm } from '../utils/form-validation.js'; 19 20// ââ Numeric field rule â CA-130 âââââââââââââââââââââââââââââââââââââââââââ 21// `#tc-est-value` was `<input type="number" step="0.01" min="0" inputmode="decimal">` until this 22// card â the one control in this family that already carried `inputmode`, so the sweep drops 23// `type`/`step`/`min` and keeps the attribute it already had. `min`/`step` were affordances, never 24// enforcement (DESIGN_BIBLE.md § "Money and Number Inputs Validate in JS â Never in HTML"); the 25// real enforcement was `app.js:107-135`, gated on the control's NATIVE numeric type â which is why 26// the pre-existing `inputmode` changed nothing and the flip changes everything. 27// 28// DERIVED FROM THE STORAGE: `becc_bids_tenders.EstimatedValue DECIMAL(18,2) NULL` 29// (Developer/Migrations/becc_bids_tables.sql:79) â two decimals, nullable; 30// `TenderWriteRequest.EstimatedValue` is `decimal?` with no `[Range]`, so the client rule owns the 31// bound and `min="0"` is the template's own floor. 32const TENDER_EST_VALUE = { minimum: 0, decimalPlaces: 2 }; 33import { DraftManager } from '../utils/draft-manager.js'; 34 35let abortController = null; 36let container = null; 37let draft = null; // create mode only 38let existingTender = null; // edit mode: the record being edited 39let isDirty = false; // edit mode dirty flag (create mode uses draft.isDirty) 40 41// ======================================== 42// PUBLIC API 43// ======================================== 44 45export function initTendersCreatePage(addOptions, existing = null) { 46 abortController = new AbortController(); 47 const signal = abortController.signal; 48 container = document.getElementById('tenders-create-page'); 49 if (!container) return; 50 51 existingTender = existing; 52 isDirty = false; 53 54 if (existingTender) { 55 // EDIT â prefill from the record (afterRender already enhanced SD/DatePicker) 56 applyData(existingTender); 57 } else { 58 // CREATE â draft persistence 59 draft = new DraftManager({ 60 key: 'sb_tender_draft', 61 container, 62 statusSelector: '#tenders-draft-status', 63 DatePicker, 64 collectFn: () => collectFormData(), 65 onRestore: (data) => restoreCheckboxes(data), 66 }); 67 draft.restore(); 68 } 69 70 wireFormEvents(signal); 71 wireBackButton(signal); 72 wireCancelButton(signal); 73 wireClearButton(signal); 74 wireSubmitButton(signal); 75 76 window.addEventListener('beforeunload', onBeforeUnload); 77} 78 79export function destroyTendersCreatePage() { 80 draft?.destroy(); 81 draft = null; 82 83 window.removeEventListener('beforeunload', onBeforeUnload); 84 85 if (container) { 86 SearchableDropdown.destroyAll(container); 87 // DatePicker instances are cleaned up when their DOM is removed 88 } 89 90 abortController?.abort(); 91 abortController = null; 92 container = null; 93 existingTender = null; 94 isDirty = false; 95} 96 97// ======================================== 98// FORM DATA 99// ======================================== 100 101function collectFormData() { 102 const data = {}; 103 if (!container) return data; 104 container.querySelectorAll('input[name], textarea[name], select[name]').forEach(el => { 105 data[el.name] = (el.type === 'checkbox') ? el.checked : el.value; 106 }); 107 return data; 108} 109 110// Draft restore doesn't handle checkboxes (DraftManager only does text/select/date) â fix here. 111function restoreCheckboxes(data) { 112 if (!container || !data) return; 113 container.querySelectorAll('input[type="checkbox"][name]').forEach(el => { 114 if (data[el.name] !== undefined) el.checked = data[el.name] === true || data[el.name] === 'true'; 115 }); 116} 117 118// EDIT prefill from the loaded tender record. 119function applyData(t) { 120 if (!container || !t) return; 121 122 // Text + textarea (skip hidden datepicker inputs + checkboxes) 123 container.querySelectorAll('input[name]:not([type="hidden"]):not([type="checkbox"]), textarea[name]').forEach(el => { 124 if (t[el.name] != null) el.value = t[el.name]; 125 }); 126 127 // Selects (estimator) â set value then let SearchableDropdown pick it up 128 container.querySelectorAll('select[name]').forEach(el => { 129 if (t[el.name] != null && t[el.name] !== '') {
130 el.value = String(t[el.name]); 131 el.dispatchEvent(new Event('change', { bubbles: true })); 132 } 133 }); 134 135 // Date fields (inputs are now type="hidden" after enhance) â via DatePicker API 136 container.querySelectorAll('input[type="hidden"][name]').forEach(el => { 137 if (t[el.name]) { 138 const dp = DatePicker.getInstance(el); 139 if (dp) dp.setValue(t[el.name]); 140 } 141 }); 142 143 // Checkboxes 144 container.querySelectorAll('input[type="checkbox"][name]').forEach(el => { 145 el.checked = !!t[el.name]; 146 }); 147} 148 149function isFormDirty() { 150 return existingTender ? isDirty : !!draft?.isDirty; 151} 152 153// ======================================== 154// EVENT WIRING 155// ======================================== 156 157function wireFormEvents(signal) { 158 const onEdit = (e) => { 159 if (existingTender) isDirty = true; 160 else draft?.scheduleSave(); 161 if (e.target.hasAttribute('data-required')) clearError(container, e.target); 162 }; 163 container.addEventListener('input', onEdit, { signal }); 164 container.addEventListener('change', onEdit, { signal }); 165} 166 167function wireBackButton(signal) { 168 container.querySelector('#btn-tenders-back')?.addEventListener('click', () => handleCancel(), { signal }); 169} 170 171function wireCancelButton(signal) { 172 container.querySelector('#btn-tenders-cancel')?.addEventListener('click', () => handleCancel(), { signal }); 173} 174 175async function handleCancel() { 176 if (isFormDirty()) { 177 const confirmed = await confirm({ 178 title: 'Discard unsaved changes?', 179 message: 'Your changes will be discarded. This cannot be undone.', 180 icon: 'trash-2', 181 confirmText: 'Discard', 182 confirmClass: 'btn-st-error', 183 // Page signal â the confirm auto-resolves false and closes on teardown, 184 // so it isn't orphaned over the next page. 185 signal: abortController?.signal, 186 }); 187 if (!confirmed) return; 188 if (!existingTender) draft?.clear(); 189 } 190 page(existingTender ? `/tenders/${existingTender.tenderId}` : '/tenders'); 191} 192 193function wireClearButton(signal) { 194 container.querySelector('#btn-tenders-clear')?.addEventListener('click', () => handleClearForm(signal), { signal }); 195} 196 197async function handleClearForm(signal) { 198 const originController = abortController; 199 const originContainer = container; 200 const originDraft = draft; 201 const cleared = await resetForm(originContainer, originDraft?.key, { 202 DatePicker, 203 SearchableDropdown, 204 signal, 205 onAfterReset: () => { 206 originContainer.querySelectorAll('input[type="checkbox"][name]').forEach(el => { el.checked = false; }); 207 } 208 }); 209 if (!cleared) return; 210 if (signal?.aborted || abortController !== originController || container !== originContainer || draft !== originDraft) return; 211 originDraft?.cancelScheduledSave(); 212 if (originDraft) originDraft.isDirty = false; 213 originDraft?.showStatus('Form cleared'); 214} 215 216function wireSubmitButton(signal) { 217 const btn = container.querySelector('#btn-tenders-submit'); 218 btn?.addEventListener('click', () => handleSubmit(btn), { signal }); 219} 220 221async function handleSubmit(btn) { 222 // CA-130 â the numeric rule runs before the loading state and before any mapping. 223 // CA-146 â `validateForm` now ACCEPTS that rule, so it no longer has to run as a second 224 // sequential pass that could only report once every presence failure was already cleared. 225 if (!validateForm(container, [ 226 { el: container?.querySelector('#tc-est-value'), label: 'Estimated Value', required: false, numeric: TENDER_EST_VALUE }, 227 ].filter(r => r.el))) return; 228 229 setLoading(btn, true, existingTender ? 'Saving...' : 'Creating...'); 230 231 try { 232 const data = collectFormData(); 233 const payload = { 234 projectName: data.projectName, 235 projectNumber: data.projectNumber || null, 236 owner: data.owner || null, 237 description: data.description || null, 238 bidStartDate: data.bidStartDate || null, 239 bidEndDate: data.bidEndDate || null, 240 address: data.address || null, 241 city: data.city || null, 242 provState: data.provState || null, 243 size: data.size || null, 244 siteVisitInfo: data.siteVisitInfo || null, 245 siteVisitDate: data.siteVisitDate || null, 246 // E1 fields â TenderNumber is auto-minted server-side, never submitted. 247 rfiDeadline: data.rfiDeadline || null, 248 // Exact `Number` semantics after the gate, never a prefix parse (intake step 6). Blank 249 // AND all-whitespace stay null â the blank contract `DECIMAL(18,2) NULL` already has. 250 estimatedValue: data.estimatedValue?.trim() ? Number(data.estimatedValue) : null, 251 siteVisitMandatory: !!data.siteVisitMandatory, 252 siteVisitHostUserId: data.siteVisitHostUserId ? parseInt(data.siteVisitHostUserId, 10) : null, 253 estimatorUserId: data.estimatorUserId ? parseInt(data.estimatorUserId, 10) : null, 254 bidForms: !!data.bidForms, 255 preQual: !!data.preQual, 256 bonds: !!data.bonds, 257 }; 258 259 const res = existingTender 260 ? await tendersApi.update(existingTender.tenderId, payload)
261 : await tendersApi.create(payload); 262 263 const savedId = res?.tender?.tenderId ?? existingTender?.tenderId; 264 265 if (existingTender) { 266 isDirty = false; 267 } else { 268 if (draft) draft.isDirty = false; 269 draft?.clear(); 270 } 271 272 window.showToast?.(existingTender ? 'Tender updated' : 'Tender created', 'success', { description: payload.projectName }); 273 page(savedId ? `/tenders/${savedId}` : '/tenders'); 274 } catch (error) { 275 if (error.name === 'AbortError') return; 276 window.showToast?.(existingTender ? 'Failed to update tender' : 'Failed to create tender', 'error', { description: error.message }); 277 setLoading(btn, false); 278 } 279} 280 281// ======================================== 282// NAVIGATION GUARD 283// ======================================== 284 285function onBeforeUnload(e) { 286 if (isFormDirty()) { 287 if (!existingTender) draft?.save(); 288 e.preventDefault(); 289 e.returnValue = ''; 290 } 291}
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.