PageSourceSearch

https://smrtbld.ai/js/pages/post-construction-create.js

js smrtbld.ai collected 2026-10-05 15:07:46 UTC 17,250 bytes, 468 lines download raw bytes

1/**
2 * post-construction-create.js — Full-page "New Post-Construction" form
3 *
4 * Features:
5 * - Draft persistence (localStorage auto-save)
6 * - Design Bible validation (red borders, shake, scroll-to-first)
7 * - Navigation guard (beforeunload)
8 * - Linked Contract Admin auto-populate (fills empty fields from selected CA project)
9 * - Warranty Expiry auto-calculation (Substantial Performance Date + Duration)
10 */
11
12import { projectsApi } from '../api/projects.js';
13import { confirm } from '../utils/modal.js';
14import { SearchableDropdown } from '../components/SearchableDropdown.js';
15import { DatePicker } from '../components/DatePicker.js';
16import { setLoading } from '../utils/dom.js';
17import { validateForm, clearError, clearAllErrors, resetForm } from '../utils/form-validation.js';
18import { DraftManager } from '../utils/draft-manager.js';
19
20
21// ========================================
22// NUMERIC FIELD DESCRIPTOR - CA-136
23// ========================================
24//
25// `#pc-budget` was `<input type="number" step="0.01" min="0">` until this card. Neither attribute
26// was enforcement (DESIGN_BIBLE.md section "Money and Number Inputs Validate in JS - Never in
27// HTML"); the app's ACTUAL enforcement was `app.js:107-135`, two document-level handlers gated on
28// the control's NATIVE numeric type, and they no longer fire on a text box.
29//
30// DERIVATION: `CreatePostConstructionRequest.ProjectBudget` and `UpdatePostConstructionRequest`
31// are both a NON-nullable `decimal`, and both paths write `becc_pm_projects_designer.Budget` -
32// `decimal(10,2)`, 8 integer digits and 2 decimals
33// (`../smartbuild.API/Repositories/SmrtBuild/Projects/ProjectRepository.cs:1582`). So 2 dp and a
34// REACHABLE ceiling of 99999999.99 that MySQL rejects past; `minimum: 0` is the template's own
35// authored `min="0"`. This is the SAME column and therefore the same rule as
36// `design-dev-create.js`'s `DESIGNER_BUDGET` (CA-132) and `deficiency-list-create.js`'s
37// `DL_NUMERICS.contractValue`.
38//
39// BLANK CONTRACT is `0`, DERIVED rather than inherited from the `|| 0` being replaced: the DTO
40// field is a non-nullable `decimal`, so there is no null to send.
41export const PC_PROJECT_BUDGET = {
42    selector: '#pc-budget', label: 'Project Budget',
43    numeric: { minimum: 0, maximum: 99999999.99, decimalPlaces: 2 },
44};
45
46/**
47 * The BOUNDED half of the post-construction numeric rules - range and precision.
48 *
49 * Well-formedness does NOT run here. It rides `data-validate="number"` on the control
50 * (`post-construction-create.html`), which puts it inside `validateForm`'s pass 2, so a missing
51 * required field and a malformed budget are marked and reported in ONE submit round rather than two
52 * (MCF-0425). Bounds cannot ride a data attribute: `data-validate` names a FORMAT from a fixed
53 * registry, and teaching it a per-field range would mean editing the shared util this card must not
54 * touch.
55 *
56 * @param {HTMLElement} root - the form container
57 * @returns {Array<Object>} rules for `validateForm`'s optional second argument (CA-146)
58 */
59export function postConstructionNumericRules(root) {
60    const el = root?.querySelector(PC_PROJECT_BUDGET.selector);
61    return el
62        ? [{ el, label: PC_PROJECT_BUDGET.label, required: false, numeric: PC_PROJECT_BUDGET.numeric }]
63        : [];
64}
65
66/**
67 * The budget as a TRIMMED raw string - one reader for the mapping in both pages.
68 *
69 * It trims for the same reason `task-modal-factory.js`'s `readRawNumeric` does: `collectFormData`
70 * reads `el.value` untrimmed, `Number('   ')` is `0`, and `validateRequired` trims before it
71 * judges - so an untrimmed mapping would store a whole `0` for a box the gate had already accepted
72 * as EMPTY. Here both answers happen to be 0, so this is a contract being stated rather than a bug
73 * being fixed; stating it is what stops the next reader from "simplifying" it back.
74 *
75 * @param {Object} data - the output of `collectFormData()`
76 * @returns {string} the trimmed entry; `''` means blank
77 */
78export function readPostConstructionBudget(data) {
79    return String(data?.projectBudget ?? '').trim();
80}
81
82let abortController = null;
83let container = null;
84let contractAdminMap = {};  // projectId → CA data for auto-populate
85let draft = null;
86
87// ========================================
88// PUBLIC API
89// ========================================
90
91export function initPostConstructionCreatePage(addOptions) {
92    abortController = new AbortController();
93    const signal = abortController.signal;
94    container = document.getElementById('pc-create-page');
95    if (!container) return;
96
97    // Build CA lookup for auto-populate
98    buildContractAdminMap(addOptions.contractAdminProjects || []);
99
100    // Pre-fill suggested project number (before draft restore so draft takes priority)
101    const numberInput = container.querySelector('#pc-project-number');
102    if (numberInput && !numberInput.value) {
103        numberInput.value = addOptions?.suggestedProjectNumber || '';
104    }
105
106    // Setup draft persistence
107    draft = new DraftManager({
108        key: 'sb_postconstruction_draft',
109        container,
110        statusSelector: '#pc-draft-status',
111        DatePicker,
112        collectFn: () => collectFormData(),
113        onRestore: () => updateLinkedNote(),
114    });
115
116    // Restore draft (before wiring events so we don't trigger dirty on restore)
117    draft.restore();
118
119    // Wire events
120    wireFormEvents(signal);
121    wireLinkedCA(signal);
122    wireWarrantyAutoCalc(signal);
123    wireBackButton(signal);
124    wireCancelButton(signal);
125    wireClearButton(signal);
126    wireSubmitButton(signal);
127
128    // Navigation guard
129    window.addEventListener('beforeunload', onBeforeUnload);
130}
131
132export function destroyPostConstructionCreatePage() {
133    // Clean up draft manager
134    draft?.destroy();
135    draft = null;
136
137    // Remove navigation guard
138    window.removeEventListener('beforeunload', onBeforeUnload);
139
140    // Destroy components
141    if (container) {
142        SearchableDropdown.destroyAll(container);
143    }
144
145    // Abort listeners
146    abortController?.abort();
147    abortController = null;
148    container = null;
149    contractAdminMap = {};
150}
151
152// ========================================
153// CONTRACT ADMIN LOOKUP
154// ========================================
155
156function buildContractAdminMap(caProjects) {
157    contractAdminMap = {};
158    for (const p of caProjects) {
159        contractAdminMap[p.projectId] = p;
160    }
161}
162
163// ========================================
164// DRAFT PERSISTENCE
165// ========================================
166
167function collectFormData() {
168    const data = {};
169    if (!container) return data;
170
171    // Text/email/number inputs
172    container.querySelectorAll('input[name], textarea[name]').forEach(el => {
173        data[el.name] = el.value;
174    });
175
176    // Selects (get actual value from original <select>, not the SD wrapper)
177    container.querySelectorAll('select[name]').forEach(el => {
178        data[el.name] = el.value;
179    });
180
181    return data;
182}
183
184// Draft persistence — delegated to DraftManager
185function clearDraft() { draft?.clear(); }
186function scheduleDraftSave() { draft?.scheduleSave(); }
187
188// ========================================
189// LINKED CONTRACT ADMIN AUTO-POPULATE
190// ========================================
191
192function wireLinkedCA(signal) {
193    const select = container.querySelector('#pc-linked-ca');
194    if (!select) return;
195
196    select.addEventListener('change', () => {
197        updateLinkedNote();
198        autoPopulateFromCA();
199        scheduleDraftSave();
200    }, { signal });
201}
202
203function updateLinkedNote() {
204    const select = container.querySelector('#pc-linked-ca');
205    const note = container.querySelector('#pc-linked-note');
206    if (!select || !note) return;
207
208    if (select.value) {
209        note.classList.remove('hidden');
210    } else {
211        note.classList.add('hidden');
212    }
213}
214
215/**
216 * Auto-populate empty fields from the selected Contract Admin project.
217 * Uses setIfEmpty() — only fills blank fields, preserves user overrides.
218 */
219function autoPopulateFromCA() {
220    const select = container.querySelector('#pc-linked-ca');
221    if (!select?.value) return;
222
223    const ca = contractAdminMap[select.value];
224    if (!ca) return;
225
226    // Text fields
227    setIfEmpty('#pc-name', ca.projectTitle);
228    setIfEmpty('#pc-budget', ca.projectBudget > 0 ? ca.projectBudget : '');
229    setIfEmpty('#pc-scope', ca.scopeOfWork);
230
231    // Date fields via DatePicker API
232    setDateIfEmpty('#pc-closeout-target', ca.submissionDueDate);
233    setDateIfEmpty('#pc-warranty-expiry', ca.anticipatedAwardDate);
234    setDateIfEmpty('#pc-poe-date', ca.anticipatedConstructionStart);
235    setDateIfEmpty('#pc-archive-deadline', ca.constructionCompletion);
236}
237
238function setIfEmpty(selector, value) {
239    if (!value) return;
240    const el = container.querySelector(selector);
241    if (!el || el.value?.trim()) return;  // already has a value
242    el.value = value;
243}
244
245function setDateIfEmpty(selector, isoString) {
246    if (!isoString) return;
247    const el = container.querySelector(selector);
248    if (!el) return;
249    // DatePicker converts to hidden — check the hidden input's value
250    if (el.value?.trim()) return;  // already has a date
251    const dp = DatePicker.getInstance(el);
252    if (dp) dp.setValue(isoString);
253}
254
255// ========================================
256// WARRANTY EXPIRY AUTO-CALCULATION
257// ========================================
258
259function wireWarrantyAutoCalc(signal) {
260    // Listen on the hidden input for substantial performance date (DatePicker converts it)
261    const substPerfEl = container.querySelector('#pc-subst-perf-date');
262    const durationEl = container.querySelector('#pc-warranty-duration');
263
264    if (substPerfEl) {
265        // DatePicker fires change on the hidden input when a date is picked
266        substPerfEl.addEventListener('change', () => recalcWarrantyExpiry(), { signal });
267    }
268
269    if (durationEl) {
270        durationEl.addEventListener('change', () => recalcWarrantyExpiry(), { signal });
271    }
272}
273
274function recalcWarrantyExpiry() {
275    const substPerfEl = container.querySelector('#pc-subst-perf-date');
276    const durationEl = container.querySelector('#pc-warranty-duration');
277    const autoLabel = container.querySelector('#pc-warranty-auto-label');
278
279    const substPerf = substPerfEl?.value;
280    const duration = durationEl?.value;
281
282    // If "Custom" or no duration selected, allow manual entry
283    if (!duration || duration === 'Custom') {
284        if (autoLabel) autoLabel.classList.add('hidden');
285        return;
286    }
287
288    if (!substPerf) {
289        if (autoLabel) autoLabel.classList.add('hidden');
290        return;
291    }
292
293    // Parse years from duration string: "1 Year (Standard)" → 1, "2 Years" → 2, etc.
294    const yearsMatch = duration.match(/^(\d+)/);
295    if (!yearsMatch) {
296        if (autoLabel) autoLabel.classList.add('hidden');
297        return;
298    }
299
300    const years = parseInt(yearsMatch[1]);
301    const perfDate = new Date(substPerf);
302    if (isNaN(perfDate.getTime())) {
303        if (autoLabel) autoLabel.classList.add('hidden');
304        return;
305    }
306
307    // Add years
308    const expiryDate = new Date(perfDate);
309    expiryDate.setFullYear(expiryDate.getFullYear() + years);
310
311    // Set warranty expiry via DatePicker API
312    const warrantyEl = container.querySelector('#pc-warranty-expiry');
313    if (warrantyEl) {
314        const dp = DatePicker.getInstance(warrantyEl);
315        if (dp) {
316            dp.setValue(expiryDate.toISOString().split('T')[0]);
317        }
318    }
319
320    // Show auto-calculated label
321    if (autoLabel) autoLabel.classList.remove('hidden');
322
323    scheduleDraftSave();
324}
325
326// ========================================
327// EVENT WIRING
328// ========================================
329
330function wireFormEvents(signal) {
331    // Auto-save on input/change + clear errors on input
332    container.addEventListener('input', (e) => {
333        scheduleDraftSave();
334        if (e.target.hasAttribute('data-required')) clearError(container, e.target);
335    }, { signal });
336
337    container.addEventListener('change', (e) => {
338        scheduleDraftSave();
339        if (e.target.hasAttribute('data-required')) clearError(container, e.target);
340    }, { signal });
341}
342
343function wireBackButton(signal) {
344    container.querySelector('#btn-pc-back')?.addEventListener('click', () => {
345        handleCancel();
346    }, { signal });
347}
348
349function wireCancelButton(signal) {
350    container.querySelector('#btn-pc-cancel')?.addEventListener('click', () => {
351        handleCancel();
352    }, { signal });
353}
354
355async function handleCancel() {
356    if (draft?.isDirty) {
357        const confirmed = await confirm({
358            title: 'Discard unsaved changes?',
359            message: 'Your draft will be discarded. This cannot be undone.',
360            icon: 'trash-2',
361            confirmText: 'Discard',
362            confirmClass: 'btn-st-error',
363            // Page signal → the confirm auto-resolves false and closes on teardown,
364            // so it isn't orphaned over the next page.
365            signal: abortController?.signal,
366        });
367        if (!confirmed) return;
368        clearDraft();
369    }
370    // Navigate back to post-construction
371    page('/post-construction');
372}
373
374function wireClearButton(signal) {
375    container.querySelector('#btn-pc-clear')?.addEventListener('click', () => handleClearForm(signal), { signal });
376}
377
378async function handleClearForm(signal) {
379    const originController = abortController;
380    const originContainer = container;
381    const originDraft = draft;
382    const cleared = await resetForm(originContainer, originDraft?.key, {
383        DatePicker,
384        SearchableDropdown,
385        signal,
386    });
387    if (!cleared) return;
388    if (signal?.aborted || abortController !== originController || container !== originContainer || draft !== originDraft) return;
389    originDraft?.cancelScheduledSave();
390    if (originDraft) originDraft.isDirty = false;
391    originDraft?.showStatus('Form cleared');
392}
393
394function wireSubmitButton(signal) {
395    const btn = container.querySelector('#btn-pc-submit');
396    if (!btn) return;
397
398    btn.addEventListener('click', () => handleSubmit(btn), { signal });
399}
400
401async function handleSubmit(btn) {
402    // Round one: required fields AND the budget's well-formedness, together
403    // (`data-validate="number"` in the template). Round two is the bounded half. Both run BEFORE
404    // the loading state, before the map and before the POST.
405    // CA-146 — ONE round: presence, format and the numeric rules are judged in a single pass.
406    if (!validateForm(container, postConstructionNumericRules(container))) return;
407
408    // Disable button
409    setLoading(btn, true, 'Creating...');
410
411    try {
412        const data = collectFormData();
413        const budgetRaw = readPostConstructionBudget(data);
414
415        const result = await projectsApi.createPostConstruction({
416            projectTitle: data.projectTitle,
417            projectNumber: data.projectNumber || null,
418            clientReferenceNumber: data.clientReferenceNumber || null,
419            projectEmail: data.projectEmail,
420            systemTimeZoneID: data.systemTimeZoneID,
421            unitOfMeasurement: data.unitOfMeasurement,
422            // CA-136: exact `Number` after the gate, never a permissive parse before it. The old
423            // expression answered 0 for GARBAGE as well as for blank, so `12abc` was silently
424            // stored as a zero-dollar budget; garbage is refused before this line runs. The BLANK
425            // contract is unchanged and DERIVED: `CreatePostConstructionRequest.ProjectBudget` is a
426            // NON-nullable `decimal`, so an empty budget is 0 and never null.
427            projectBudget: budgetRaw === '' ? 0 : Number(budgetRaw),
428            scopeOfWork: data.scopeOfWork || null,
429            submissionDueDate: data.submissionDueDate || null,
430            anticipatedAwardDate: data.anticipatedAwardDate || null,
431            anticipatedConstructionStart: data.anticipatedConstructionStart || null,
432            constructionCompletion: data.constructionCompletion || null,
433            linkedContractAdminId: data.linkedContractAdminId ? parseInt(data.linkedContractAdminId) : null,
434            substantialPerformanceDate: data.substantialPerformanceDate || null,
435            warrantyDuration: data.warrantyDuration,
436            buildingOccupancyStatus: data.buildingOccupancyStatus,
437            postOccupancyEvalScope: data.postOccupancyEvalScope || null
438        });
439
440        // Success — clear draft and navigate
441        if (draft) draft.isDirty = false;
442        clearDraft();
443        window.showToast?.('Post-construction project created', 'success', { description: data.projectTitle });
444
445        if (result.projectId) {
446            page(`/post-construction/${result.projectId}`);
447        } else {
448            page('/post-construction');
449        }
450    } catch (error) {
451        if (error.name === 'AbortError') return;
452        window.showToast?.('Failed to create post-construction project', 'error', { description: error.message });
453        setLoading(btn, false);
454    }
455}
456
457// ========================================
458// NAVIGATION GUARD
459// ========================================
460
461function onBeforeUnload(e) {
462    if (draft?.isDirty) {
463        // Save draft before leaving
464        draft.save();
465        e.preventDefault();
466        e.returnValue = '';
467    }
468}

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.