PageSourceSearch

https://smrtbld.ai/js/pages/design-dev-create.js

js smrtbld.ai collected 2026-10-05 15:07:32 UTC 12,407 bytes, 356 lines download raw bytes

1/**
2 * design-dev-create.js — Full-page "New Design & Dev" form
3 *
4 * Replaces the old modal-based create flow. Features:
5 * - Draft persistence (localStorage auto-save)
6 * - Design Bible validation (red borders, shake, scroll-to-first)
7 * - Navigation guard (beforeunload)
8 * - Linked Proposal auto-populate (fills empty fields from selected proposal)
9 */
10
11import { projectsApi } from '../api/projects.js';
12import { confirm } from '../utils/modal.js';
13import { SearchableDropdown } from '../components/SearchableDropdown.js';
14import { DatePicker } from '../components/DatePicker.js';
15import { setLoading } from '../utils/dom.js';
16import { validateForm, clearError, clearAllErrors, resetForm } from '../utils/form-validation.js';
17
18/**
19 * The Project Budget rule — CA-132, shared with `design-dev-edit.js`.
20 *
21 * `#dd-budget` was `<input type="number" min="0" step="0.01">` until this card; `min`/`step` were
22 * never enforcement and `app.js:107-135` (which was) no longer fires on a text box.
23 *
24 * DERIVATION: both the create and the edit path write `becc_pm_projects_designer.Budget`, which is
25 * `decimal(10,2)` — 8 integer digits and 2 decimals — behind a NON-nullable `decimal` DTO. So 2 dp,
26 * a real ceiling of 99999999.99 that MySQL rejects past (the rule turns that 500 into a sentence),
27 * and `minimum: 0` from the template's own authored `min="0"`.
28 */
29export const DESIGNER_BUDGET = { minimum: 0, maximum: 99999999.99, decimalPlaces: 2 };
30import { DraftManager } from '../utils/draft-manager.js';
31
32let abortController = null;
33let container = null;
34let proposalsMap = {};  // projectId → proposal data for auto-populate
35let draft = null;
36
37// ========================================
38// PUBLIC API
39// ========================================
40
41export function initDesignDevCreatePage(addOptions) {
42    abortController = new AbortController();
43    const signal = abortController.signal;
44    container = document.getElementById('dd-create-page');
45    if (!container) return;
46
47    // Build proposals lookup for auto-populate
48    buildProposalsMap(addOptions.proposals || []);
49
50    // Pre-fill suggested project number (before draft restore so draft takes priority)
51    const numberInput = container.querySelector('#dd-number');
52    if (numberInput && !numberInput.value) {
53        numberInput.value = addOptions?.suggestedProjectNumber || '';
54    }
55
56    // Setup draft persistence
57    draft = new DraftManager({
58        key: 'sb_designdev_draft',
59        container,
60        statusSelector: '#dd-draft-status',
61        DatePicker,
62        collectFn: () => collectFormData(),
63        onRestore: () => updateLinkedNote(),
64    });
65
66    // Restore draft (before wiring events so we don't trigger dirty on restore)
67    draft.restore();
68
69    // Wire events
70    wireFormEvents(signal);
71    wireLinkedProposal(signal);
72    wireBackButton(signal);
73    wireCancelButton(signal);
74    wireClearButton(signal);
75    wireSubmitButton(signal);
76
77    // Navigation guard
78    window.addEventListener('beforeunload', onBeforeUnload);
79}
80
81export function destroyDesignDevCreatePage() {
82    // Clean up draft manager
83    draft?.destroy();
84    draft = null;
85
86    // Remove navigation guard
87    window.removeEventListener('beforeunload', onBeforeUnload);
88
89    // Destroy components
90    if (container) {
91        SearchableDropdown.destroyAll(container);
92    }
93
94    // Abort listeners
95    abortController?.abort();
96    abortController = null;
97    container = null;
98    proposalsMap = {};
99}
100
101// ========================================
102// PROPOSALS LOOKUP
103// ========================================
104
105function buildProposalsMap(proposals) {
106    proposalsMap = {};
107    for (const p of proposals) {
108        proposalsMap[p.projectId] = p;
109    }
110}
111
112// ========================================
113// DRAFT PERSISTENCE
114// ========================================
115
116function collectFormData() {
117    const data = {};
118    if (!container) return data;
119
120    // Text/email/number inputs
121    container.querySelectorAll('input[name], textarea[name]').forEach(el => {
122        data[el.name] = el.value;
123    });
124
125    // Selects (get actual value from original <select>, not the SD wrapper)
126    container.querySelectorAll('select[name]').forEach(el => {
127        data[el.name] = el.value;
128    });
129
130    return data;
131}
132
133// Draft persistence — delegated to DraftManager
134function clearDraft() { draft?.clear(); }
135function scheduleDraftSave() { draft?.scheduleSave(); }
136
137// ========================================
138// LINKED PROPOSAL AUTO-POPULATE
139// ========================================
140
141function wireLinkedProposal(signal) {
142    const select = container.querySelector('#dd-linked-proposal');
143    if (!select) return;
144
145    select.addEventListener('change', () => {
146        updateLinkedNote();
147        autoPopulateFromProposal();
148        scheduleDraftSave();
149    }, { signal });
150}
151
152function updateLinkedNote() {
153    const select = container.querySelector('#dd-linked-proposal');
154    const note = container.querySelector('#dd-linked-note');
155    if (!select || !note) return;
156
157    if (select.value) {
158        note.classList.remove('hidden');
159    } else {
160        note.classList.add('hidden');
161    }
162}
163
164/**
165 * Auto-populate empty fields from the selected proposal.
166 * Uses setIfEmpty() — only fills blank fields, preserves user overrides.
167 */
168function autoPopulateFromProposal() {
169    const select = container.querySelector('#dd-linked-proposal');
170    if (!select?.value) return;
171
172    const proposal = proposalsMap[select.value];
173    if (!proposal) return;
174
175    // Text fields
176    setIfEmpty('#dd-name', proposal.projectTitle);
177    setIfEmpty('#dd-client-ref', proposal.clientReferenceNumber);
178    setIfEmpty('#dd-budget', proposal.projectBudget > 0 ? proposal.projectBudget : '');
179    setIfEmpty('#dd-scope', proposal.scopeOfWork);
180
181    // Select fields — only set if current value is empty
182    setSelectIfEmpty('#dd-building-class', proposal.buildingClassification);
183
184    // Date fields via DatePicker API
185    setDateIfEmpty('#dd-submission-due', proposal.submissionDueDate);
186    setDateIfEmpty('#dd-award-date', proposal.anticipatedAwardDate);
187    setDateIfEmpty('#dd-con-start', proposal.anticipatedConstructionStart);
188    setDateIfEmpty('#dd-con-completion', proposal.constructionCompletion);
189}
190
191function setIfEmpty(selector, value) {
192    if (!value) return;
193    const el = container.querySelector(selector);
194    if (!el || el.value?.trim()) return;  // already has a value
195    el.value = value;
196}
197
198function setSelectIfEmpty(selector, value) {
199    if (!value) return;
200    const el = container.querySelector(selector);
201    if (!el || el.value) return;  // already selected
202    el.value = value;
203    el.dispatchEvent(new Event('change', { bubbles: true }));
204}
205
206function setDateIfEmpty(selector, isoString) {
207    if (!isoString) return;
208    const el = container.querySelector(selector);
209    if (!el) return;
210    // DatePicker converts to hidden — check the hidden input's value
211    if (el.value?.trim()) return;  // already has a date
212    const dp = DatePicker.getInstance(el);
213    if (dp) dp.setValue(isoString);
214}
215
216// ========================================
217// EVENT WIRING
218// ========================================
219
220function wireFormEvents(signal) {
221    // Auto-save on input/change + clear errors on input
222    container.addEventListener('input', (e) => {
223        scheduleDraftSave();
224        if (e.target.hasAttribute('data-required')) clearError(container, e.target);
225    }, { signal });
226
227    container.addEventListener('change', (e) => {
228        scheduleDraftSave();
229        if (e.target.hasAttribute('data-required')) clearError(container, e.target);
230    }, { signal });
231}
232
233function wireBackButton(signal) {
234    container.querySelector('#btn-dd-back')?.addEventListener('click', () => {
235        handleCancel();
236    }, { signal });
237}
238
239function wireCancelButton(signal) {
240    container.querySelector('#btn-dd-cancel')?.addEventListener('click', () => {
241        handleCancel();
242    }, { signal });
243}
244
245async function handleCancel() {
246    if (draft?.isDirty) {
247        const confirmed = await confirm({
248            title: 'Discard unsaved changes?',
249            message: 'Your draft will be discarded. This cannot be undone.',
250            icon: 'trash-2',
251            confirmText: 'Discard',
252            confirmClass: 'btn-st-error',
253            // Page signal → the confirm auto-resolves false and closes on teardown,
254            // so it isn't orphaned over the next page.
255            signal: abortController?.signal,
256        });
257        if (!confirmed) return;
258        clearDraft();
259    }
260    // Navigate back to design & dev list
261    page('/design-dev');
262}
263
264function wireClearButton(signal) {
265    container.querySelector('#btn-dd-clear')?.addEventListener('click', () => handleClearForm(signal), { signal });
266}
267
268async function handleClearForm(signal) {
269    const originController = abortController;
270    const originContainer = container;
271    const originDraft = draft;
272    const cleared = await resetForm(originContainer, originDraft?.key, {
273        DatePicker,
274        SearchableDropdown,
275        signal,
276    });
277    if (!cleared) return;
278    if (signal?.aborted || abortController !== originController || container !== originContainer || draft !== originDraft) return;
279    originDraft?.cancelScheduledSave();
280    if (originDraft) originDraft.isDirty = false;
281    originDraft?.showStatus('Form cleared');
282}
283
284function wireSubmitButton(signal) {
285    const btn = container.querySelector('#btn-dd-submit');
286    if (!btn) return;
287
288    btn.addEventListener('click', () => handleSubmit(btn), { signal });
289}
290
291async function handleSubmit(btn) {
292    // CA-132 — before the loading state, before the map, before the POST.
293    // CA-146 — the numeric rule rides `validateForm`'s own pass, so a missing required field and an
294    // unreadable budget are marked, summarised and announced in ONE submit round.
295    if (!validateForm(container, [
296        { el: container.querySelector('#dd-budget'), label: 'Project Budget', required: false, numeric: DESIGNER_BUDGET },
297    ])) return;
298
299    // Disable button
300    setLoading(btn, true, 'Creating...');
301
302    try {
303        const data = collectFormData();
304
305        const result = await projectsApi.createDesignDev({
306            projectTitle: data.projectTitle,
307            projectNumber: data.projectNumber || null,
308            clientReferenceNumber: data.clientReferenceNumber || null,
309            projectEmail: data.projectEmail,
310            systemTimeZoneID: data.systemTimeZoneID,
311            unitOfMeasurement: data.unitOfMeasurement,
312            // CA-132: exact `Number`, not `parseFloat`. Blank contract unchanged and stated —
313            // `CreateDesignDevRequest.ProjectBudget` is a NON-nullable `decimal`, so a blank box
314            // has always meant 0 here.
315            projectBudget: data.projectBudget ? Number(data.projectBudget) : 0,
316            scopeOfWork: data.scopeOfWork || null,
317            submissionDueDate: data.submissionDueDate || null,
318            anticipatedConstructionStart: data.anticipatedConstructionStart || null,
319            constructionCompletion: data.constructionCompletion || null,
320            anticipatedAwardDate: data.anticipatedAwardDate || null,
321            linkedProposalId: data.linkedProposalId ? parseInt(data.linkedProposalId) : null,
322            deliveryMethod: data.deliveryMethod,
323            buildingClassification: data.buildingClassification || null,
324            constructionType: data.constructionType || null,
325            currentPhase: data.currentPhase
326        });
327
328        // Success — clear draft and navigate
329        if (draft) draft.isDirty = false;
330        clearDraft();
331        window.showToast?.('Design & Dev project created', 'success', { description: data.projectTitle });
332
333        if (result.projectId) {
334            page(`/design-dev/${result.projectId}`);
335        } else {
336            page('/design-dev');
337        }
338    } catch (error) {
339        if (error.name === 'AbortError') return;
340        window.showToast?.('Failed to create project', 'error', { description: error.message });
341        setLoading(btn, false);
342    }
343}
344
345// ========================================
346// NAVIGATION GUARD
347// ========================================
348
349function onBeforeUnload(e) {
350    if (draft?.isDirty) {
351        // Save draft before leaving
352        draft.save();
353        e.preventDefault();
354        e.returnValue = '';
355    }
356}

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.