PageSourceSearch

https://smrtbld.ai/js/pages/tenders-create.js

js smrtbld.ai collected 2026-10-05 15:07:36 UTC 11,670 bytes, 291 lines download raw bytes

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.