PageSourceSearch

https://smrtbld.ai/js/pages/deficiency-list-edit.js

js smrtbld.ai collected 2026-10-05 15:08:11 UTC 17,124 bytes, 479 lines download raw bytes

1/**
2 * deficiency-list-edit.js — Full-page "Edit Deficiency List" form
3 *
4 * Reuses the deficiency-list-create.html template (with isEdit=true conditionals).
5 * Key differences from deficiency-list-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 (updateDeficiencyList) instead of POST (createDeficiencyList)
9 * - Navigation goes to /deficiency-list/{id} (not /deficiency-list)
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';
19import { populateEditForm } from '../utils/form-population.js';
20// CA-136: the create page owns the numeric descriptors, the rule builder and the trimmed reader;
21// this page reuses the same rules on the same template rather than authoring a second copy that
22// could drift. Both pages render `deficiency-list-create.html` (with `isEdit` conditionals).
23import { deficiencyListNumericRules, readDeficiencyListNumerics } from './deficiency-list-create.js';
24import { DraftManager } from '../utils/draft-manager.js';
25
26// Provincial lien period defaults (days)
27const LIEN_DEFAULTS = {
28    'ON': 60, 'AB': 60, 'BC': 55, 'SK': 60, 'MB': 40,
29    'QC': 30, 'NB': 60, 'NS': 60, 'PE': 60, 'NL': 60,
30    'NT': 60, 'YT': 60, 'NU': 60
31};
32
33let abortController = null;
34let isDirty = false;
35let container = null;
36let currentId = null;
37let draft = null;
38
39// ========================================
40// PUBLIC API
41// ========================================
42
43export function initDeficiencyListEditPage(id, addOptions, data) {
44    abortController = new AbortController();
45    const signal = abortController.signal;
46    container = document.getElementById('dl-create-page');
47    currentId = id;
48    if (!container) return;
49
50    // Pre-populate form with existing data
51    populateForm(data, addOptions);
52
53    // Edit-backup: any in-flight edits from a prior session override the
54    // server values. Key is scoped per-record. Restore AFTER populateForm —
55    // draft wins so user edits aren't clobbered.
56    draft = new DraftManager({
57        key: `sb_draft_deficiency_list_edit_${id}`,
58        container,
59        statusSelector: '#dl-draft-status',
60        DatePicker,
61        collectFn: () => collectFormData(),
62        onRestore: (data) => {
63            // Restore warranty custom toggle visibility
64            if (data.warrantyPeriod === 'custom') {
65                const customGroup = container.querySelector('#dl-warranty-custom-group');
66                if (customGroup) customGroup.classList.remove('hidden');
67            }
68            // Check date order after restore
69            checkDateOrder();
70            isDirty = true;   // a restored backup IS dirty work
71        },
72    });
73    draft.restore();
74
75    // Wire events
76    wireFormEvents(signal);
77    wireProvinceToLienPeriod(signal);
78    wireWarrantyToggle(signal);
79    wireDateWarnings(signal);
80    wireBackButton(signal);
81    wireCancelButton(signal);
82    wireSubmitButton(signal);
83
84    // Navigation guard
85    window.addEventListener('beforeunload', onBeforeUnload);
86}
87
88export function destroyDeficiencyListEditPage() {
89    window.removeEventListener('beforeunload', onBeforeUnload);
90
91    draft?.destroy();
92    draft = null;
93
94    if (container) {
95        SearchableDropdown.destroyAll(container);
96    }
97
98    abortController?.abort();
99    abortController = null;
100    container = null;
101    isDirty = false;
102    currentId = null;
103}
104
105// ========================================
106// FORM PRE-POPULATION
107// ========================================
108
109function populateForm(data, addOptions) {
110    if (!data || !container) return;
111
112    // Field name mapping: API response key → form input name attr
113    const fieldMap = {
114        projectTitle: 'projectName',
115        projectNumber: 'projectNumber',
116        clientName: 'clientName',
117        clientContact: 'clientContact',
118        projectEmail: 'projectEmail',
119        projectAddress: 'siteAddress',
120        projectCity: 'city',
121        contractNum: 'contractNumber',
122        generalContractor: 'gcCompany',
123        siteSuperintendent: 'gcContact',
124        gcEmail: 'gcEmail',
125        gcPhone: 'gcPhone',
126        scopeOfWork: 'notes',
127    };
128
129    // Map API → form names, then set text inputs
130    const mapped = {};
131    for (const [apiKey, formName] of Object.entries(fieldMap)) {
132        if (data[apiKey] !== undefined && data[apiKey] !== null) {
133            mapped[formName] = data[apiKey];
134        }
135    }
136
137    // Contract value (number)
138    if (data.projectBudget != null) {
139        mapped.contractValue = data.projectBudget;
140    }
141
142    // Holdback percentage
143    if (data.holdbackPercentage != null) {
144        mapped.holdbackPct = data.holdbackPercentage;
145    }
146
147    // Lien period
148    if (data.lienPeriodDays != null) {
149        mapped.lienPeriod = data.lienPeriodDays;
150    }
151
152    // Populate text inputs + textareas via shared utility
153    // (selects and dates handled separately below due to custom ID-based mapping)
154    populateEditForm(container, mapped, {
155        currencyFields: ['contractValue'],
156    });
157
158    // Province select
159    if (data.projectProvince) {
160        const provinceEl = container.querySelector('#dl-province');
161        if (provinceEl) {
162            provinceEl.value = data.projectProvince;
163            provinceEl.dispatchEvent(new Event('change', { bubbles: true }));
164        }
165    }
166
167    // Contract type select
168    if (data.contractType) {
169        const ctEl = container.querySelector('#dl-contract-type');
170        if (ctEl) {
171            ctEl.value = data.contractType;
172            ctEl.dispatchEvent(new Event('change', { bubbles: true }));
173        }
174    }
175
176    // Warranty period — resolve to preset select value or custom
177    if (data.defaultWarrantyMonths != null) {
178        const warrantySelect = container.querySelector('#dl-warranty-period');
179        const customGroup = container.querySelector('#dl-warranty-custom-group');
180        const customInput = container.querySelector('#dl-custom-warranty');
181        const presetValues = ['12', '24', '36', '60'];
182        const months = String(data.defaultWarrantyMonths);
183
184        if (warrantySelect) {
185            if (presetValues.includes(months)) {
186                warrantySelect.value = months;
187            } else {
188                warrantySelect.value = 'custom';
189                if (customGroup) customGroup.classList.remove('hidden');
190                if (customInput) customInput.value = months;
191            }
192            warrantySelect.dispatchEvent(new Event('change', { bubbles: true }));
193        }
194    }
195
196    // Team select dropdowns (user IDs)
197    if (data.principalUserId) {
198        const el = container.querySelector('#dl-principal');
199        if (el) {
200            el.value = String(data.principalUserId);
201            el.dispatchEvent(new Event('change', { bubbles: true }));
202        }
203    }
204    if (data.managerUserId) {
205        const el = container.querySelector('#dl-pm');
206        if (el) {
207            el.value = String(data.managerUserId);
208            el.dispatchEvent(new Event('change', { bubbles: true }));
209        }
210    }
211    if (data.leadInspectorUserId) {
212        const el = container.querySelector('#dl-inspector');
213        if (el) {
214            el.value = String(data.leadInspectorUserId);
215            el.dispatchEvent(new Event('change', { bubbles: true }));
216        }
217    }
218
219    // Hidden fields — deficiency list type
220    if (data.deficiencyListType) {
221        const dtEl = container.querySelector('#dl-type');
222        if (dtEl) dtEl.value = data.deficiencyListType;
223    }
224
225    // Date fields via DatePicker API
226    // API response key → form input name
227    const dateMap = {
228        submissionDueDate: 'substantialPerf',
229        anticipatedConstructionStart: 'constructionStart',
230        constructionCompletion: 'contractualCompletion',
231        anticipatedAwardDate: 'expectedCloseout',
232    };
233
234    for (const [apiKey, formName] of Object.entries(dateMap)) {
235        const val = data[apiKey];
236        if (val) {
237            const hiddenInput = container.querySelector(`input[name="${formName}"]`);
238            if (hiddenInput) {
239                const dp = DatePicker.getInstance(hiddenInput);
240                if (dp) dp.setValue(val);
241            }
242        }
243    }
244
245    // Check date order after populating
246    checkDateOrder();
247}
248
249// ========================================
250// FORM DATA COLLECTION
251// ========================================
252
253function collectFormData() {
254    const data = {};
255    if (!container) return data;
256
257    container.querySelectorAll('input[name], textarea[name]').forEach(el => {
258        data[el.name] = el.value;
259    });
260
261    container.querySelectorAll('select[name]').forEach(el => {
262        data[el.name] = el.value;
263    });
264
265    return data;
266}
267
268// ========================================
269// EVENT WIRING
270// ========================================
271
272function wireFormEvents(signal) {
273    container.addEventListener('input', (e) => {
274        isDirty = true;
275        draft?.scheduleSave();
276        if (e.target.hasAttribute('data-required')) clearError(container, e.target);
277    }, { signal });
278
279    container.addEventListener('change', (e) => {
280        isDirty = true;
281        draft?.scheduleSave();
282        if (e.target.hasAttribute('data-required')) clearError(container, e.target);
283    }, { signal });
284}
285
286function wireProvinceToLienPeriod(signal) {
287    const provinceInput = container.querySelector('#dl-province');
288    const lienInput = container.querySelector('#dl-lien-period');
289    if (!provinceInput || !lienInput) return;
290
291    provinceInput.addEventListener('change', () => {
292        const currentLien = lienInput.value;
293        if (currentLien && currentLien !== '60') return;
294
295        const province = provinceInput.value.trim().toUpperCase();
296        if (LIEN_DEFAULTS[province] !== undefined) {
297            lienInput.value = LIEN_DEFAULTS[province];
298        }
299    }, { signal });
300}
301
302function wireWarrantyToggle(signal) {
303    const warrantySelect = container.querySelector('#dl-warranty-period');
304    const customGroup = container.querySelector('#dl-warranty-custom-group');
305    const customInput = container.querySelector('#dl-custom-warranty');
306    if (!warrantySelect || !customGroup) return;
307
308    warrantySelect.addEventListener('change', () => {
309        const isCustom = warrantySelect.value === 'custom';
310        customGroup.classList.toggle('hidden', !isCustom);
311        if (!isCustom && customInput) customInput.value = '';
312    }, { signal });
313}
314
315function wireDateWarnings(signal) {
316    container.addEventListener('change', (e) => {
317        const dateNames = ['constructionStart', 'substantialPerf', 'contractualCompletion', 'expectedCloseout'];
318        if (dateNames.includes(e.target.name)) {
319            checkDateOrder();
320        }
321    }, { signal });
322}
323
324function checkDateOrder() {
325    const warningEl = container.querySelector('#dl-date-warning');
326    if (!warningEl) return;
327
328    const getDateVal = (name) => {
329        const el = container.querySelector(`[name="${name}"]`);
330        return el?.value ? new Date(el.value) : null;
331    };
332
333    const dates = [
334        getDateVal('constructionStart'),
335        getDateVal('substantialPerf'),
336        getDateVal('contractualCompletion'),
337        getDateVal('expectedCloseout')
338    ].filter(Boolean);
339
340    let outOfOrder = false;
341    for (let i = 1; i < dates.length; i++) {
342        if (dates[i] < dates[i - 1]) {
343            outOfOrder = true;
344            break;
345        }
346    }
347
348    warningEl.classList.toggle('hidden', !outOfOrder);
349}
350
351// ========================================
352// NAVIGATION
353// ========================================
354
355function wireBackButton(signal) {
356    container.querySelector('#btn-dl-back')?.addEventListener('click', () => {
357        handleCancel();
358    }, { signal });
359}
360
361function wireCancelButton(signal) {
362    container.querySelector('#btn-dl-cancel')?.addEventListener('click', () => {
363        handleCancel();
364    }, { signal });
365}
366
367async function handleCancel() {
368    if (isDirty) {
369        const confirmed = await confirm({
370            title: 'Discard unsaved changes?',
371            message: 'Any changes you made will be lost.',
372            icon: 'trash-2',
373            confirmText: 'Discard',
374            confirmClass: 'btn-st-error',
375            // Page signal → the confirm auto-resolves false and closes on teardown,
376            // so it isn't orphaned over the next page.
377            signal: abortController?.signal,
378        });
379        if (!confirmed) return;
380        draft?.clear();
381    }
382    page(`/deficiency-list/${currentId}`);
383}
384
385function wireSubmitButton(signal) {
386    const btn = container.querySelector('#btn-dl-submit');
387    if (!btn) return;
388
389    btn.addEventListener('click', () => handleSubmit(btn), { signal });
390}
391
392async function handleSubmit(btn) {
393    // Round one: required fields AND every swept control's well-formedness, together
394    // (`data-validate="number"` in the template). Round two is the bounded half. Both run BEFORE
395    // the loading state, before the map and before the PUT.
396    // CA-146 — ONE round: presence, format and the numeric rules are judged in a single pass.
397    if (!validateForm(container, deficiencyListNumericRules(container))) return;
398
399    setLoading(btn, true, 'Saving...');
400
401    try {
402        const data = collectFormData();
403        const num = readDeficiencyListNumerics(data);
404
405        // Resolve warranty months — preset or custom. CA-136: same repair as the create page; the
406        // preset arm's `parseInt` reads an authored `<select>` option value and is ALREADY-SAFE.
407        let warrantyMonths = null;
408        if (data.warrantyPeriod === 'custom') {
409            warrantyMonths = num.customWarranty === '' ? null : Number(num.customWarranty);
410        } else if (data.warrantyPeriod) {
411            warrantyMonths = parseInt(data.warrantyPeriod);
412        }
413
414        await projectsApi.updateDeficiencyList(currentId, {
415            projectTitle: data.projectName,
416            projectEmail: data.projectEmail,
417            deficiencyListType: data.deficiencyListType || 'Substantial Performance',
418
419            // Section 1: Project Information
420            projectNumber: data.projectNumber || null,
421            clientName: data.clientName || null,
422            clientContact: data.clientContact || null,
423            projectAddress: data.siteAddress || null,
424            projectCity: data.city || null,
425            projectProvince: data.province || null,
426
427            // Section 2: Contract Details
428            contractType: data.contractType || null,
429            contractNumber: data.contractNumber || null,
430            // CA-136: exact `Number` after the gate. Blank contracts derived from
431            // `UpdateDeficiencyListRequest` - `ProjectBudget` is a NON-nullable `decimal` so blank
432            // is 0; `HoldbackPercentage` is `decimal?` so blank is null.
433            projectBudget: num.contractValue === '' ? 0 : Number(num.contractValue),
434            holdbackPercentage: num.holdbackPct === '' ? null : Number(num.holdbackPct),
435            generalContractor: data.gcCompany || null,
436            siteSuperintendent: data.gcContact || null,
437            gcEmail: data.gcEmail || null,
438            gcPhone: data.gcPhone || null,
439
440            // Section 3: Key Dates
441            anticipatedConstructionStart: data.constructionStart || null,
442            submissionDueDate: data.substantialPerf || null,
443            constructionCompletion: data.contractualCompletion || null,
444            anticipatedAwardDate: data.expectedCloseout || null,
445            // `LienPeriodDays` is `int?` -> blank is null.
446            lienPeriodDays: num.lienPeriod === '' ? null : Number(num.lienPeriod),
447            defaultWarrantyMonths: warrantyMonths,
448
449            // Section 4: Team Assignment
450            principalUserId: data.principal ? parseInt(data.principal) : null,
451            projectManagerUserId: data.pm ? parseInt(data.pm) : null,
452            leadInspectorUserId: data.inspector ? parseInt(data.inspector) : null,
453
454            // Section 5: Notes
455            scopeOfWork: data.notes || null,
456        });
457
458        isDirty = false;
459        draft?.clear();
460        window.showToast?.('Deficiency list updated', 'success', { description: data.projectName });
461        page(`/deficiency-list/${currentId}`);
462    } catch (error) {
463        if (error.name === 'AbortError') return;
464        window.showToast?.('Failed to update project', 'error', { description: error.message });
465        setLoading(btn, false);
466    }
467}
468
469// ========================================
470// NAVIGATION GUARD
471// ========================================
472
473function onBeforeUnload(e) {
474    if (isDirty) {
475        draft?.save();          // synchronous localStorage write — survives browser close
476        e.preventDefault();
477        e.returnValue = '';
478    }
479}

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.