PageSourceSearch

https://smrtbld.ai/js/pages/project-pursuit-create.js

js smrtbld.ai collected 2026-10-05 15:07:27 UTC 10,093 bytes, 275 lines download raw bytes

1/**
2 * project-pursuit-create.js — Full-page "New Pursuit" form
3 *
4 * Draft persistence (localStorage), Design Bible validation,
5 * navigation guard (beforeunload).
6 */
7
8import { pursuitApi } from '../api/project-pursuits.js';
9import { todayISO } from '../utils/time-utils.js';
10import { confirm } from '../utils/modal.js';
11import { SearchableDropdown } from '../components/SearchableDropdown.js';
12import { DatePicker } from '../components/DatePicker.js';
13import { setLoading } from '../utils/dom.js';
14import { validateForm, clearError, resetForm } from '../utils/form-validation.js';
15import { DraftManager } from '../utils/draft-manager.js';
16
17let abortController = null;
18let container = null;
19let draft = null;
20
21// ── Numeric field descriptor — CA-133 ───────────────────────────────────────
22// `#pp-budget` (`project-pursuit-create.html:142`) was `<input type="number" step="0.01" min="0">`
23// until this card. Those attributes were affordances, never enforcement (DESIGN_BIBLE.md § "Money
24// and Number Inputs Validate in JS — Never in HTML"); the app's real numeric enforcement was
25// `app.js:107-135`, whose two document-level handlers are gated on the control's native numeric
26// type and therefore no longer fire on this box. Constraint DERIVED from
27// `CreatePursuitRequest.EstimatedBudget` (`decimal?`) and the stored column
28// `becc_pm_projecttasks_pursuit.EstimatedBudget` (`decimal(15,2)` NULL): two decimal places,
29// nullable, no ceiling worth stating at 10^13.
30const PURSUIT_BUDGET = { minimum: 0, decimalPlaces: 2 };
31
32// ========================================
33// PUBLIC API
34// ========================================
35
36export function initProjectPursuitCreatePage(addOptions) {
37    abortController = new AbortController();
38    const signal = abortController.signal;
39    container = document.getElementById('pursuit-create-page');
40    if (!container) return;
41
42    // Set default date to today for Date Identified
43    applyDefaultDateIdentified();
44
45    // Pre-fill suggested project number (before draft restore so draft takes priority)
46    const numberInput = container.querySelector('#pp-number');
47    if (numberInput && !numberInput.value) {
48        numberInput.value = addOptions?.suggestedProjectNumber || '';
49    }
50
51    // Setup draft persistence
52    draft = new DraftManager({
53        key: 'sb_pursuit_draft',
54        container,
55        statusSelector: '#pp-draft-status',
56        DatePicker,
57        collectFn: () => collectFormData(),
58    });
59
60    // Restore draft (before wiring events so we don't trigger dirty on restore)
61    draft.restore();
62
63    // Wire events
64    wireFormEvents(signal);
65    wireBackButton(signal);
66    wireCancelButton(signal);
67    wireClearButton(signal);
68    wireSubmitButton(signal);
69
70    // Navigation guard
71    window.addEventListener('beforeunload', onBeforeUnload);
72}
73
74export function destroyProjectPursuitCreatePage() {
75    // Clean up draft manager
76    draft?.destroy();
77    draft = null;
78
79    window.removeEventListener('beforeunload', onBeforeUnload);
80
81    if (container) {
82        SearchableDropdown.destroyAll(container);
83    }
84
85    abortController?.abort();
86    abortController = null;
87    container = null;
88}
89
90// ========================================
91// DRAFT PERSISTENCE
92// ========================================
93
94function collectFormData() {
95    const data = {};
96    if (!container) return data;
97
98    container.querySelectorAll('input[name], textarea[name]').forEach(el => {
99        data[el.name] = el.value;
100    });
101
102    container.querySelectorAll('select[name]').forEach(el => {
103        data[el.name] = el.value;
104    });
105
106    return data;
107}
108
109// Draft persistence — delegated to DraftManager
110function clearDraft() { draft?.clear(); }
111function scheduleDraftSave() { draft?.scheduleSave(); }
112
113// ========================================
114// EVENT WIRING
115// ========================================
116
117function wireFormEvents(signal) {
118    container.addEventListener('input', (e) => {
119        scheduleDraftSave();
120        if (e.target.hasAttribute('data-required')) clearError(container, e.target);
121    }, { signal });
122
123    container.addEventListener('change', (e) => {
124        scheduleDraftSave();
125        if (e.target.hasAttribute('data-required')) clearError(container, e.target);
126    }, { signal });
127
128    // Lead Source → conditional "Referred By" field
129    const leadSourceEl = container.querySelector('#pp-lead-source');
130    const referredByRow = container.querySelector('#pp-referred-by-row');
131    if (leadSourceEl && referredByRow) {
132        const handleSourceChange = () => {
133            const sd = SearchableDropdown.getInstance(leadSourceEl);
134            const val = sd ? sd.getValue() : leadSourceEl.value;
135            referredByRow.classList.toggle('hidden', val !== 'Referral');
136        };
137        leadSourceEl.addEventListener('change', handleSourceChange, { signal });
138        // Check initial state (for draft restore)
139        handleSourceChange();
140    }
141}
142
143function wireBackButton(signal) {
144    container.querySelector('#btn-pursuit-back')?.addEventListener('click', () => {
145        handleCancel();
146    }, { signal });
147}
148
149function wireCancelButton(signal) {
150    container.querySelector('#btn-pursuit-cancel')?.addEventListener('click', () => {
151        handleCancel();
152    }, { signal });
153}
154
155async function handleCancel() {
156    if (draft?.isDirty) {
157        const confirmed = await confirm({
158            title: 'Discard unsaved changes?',
159            message: 'Your draft will be discarded. This cannot be undone.',
160            icon: 'trash-2',
161            confirmText: 'Discard',
162            confirmClass: 'btn-st-error',
163            // Page signal → the confirm auto-resolves false and closes on teardown,
164            // so it isn't orphaned over the next page.
165            signal: abortController?.signal,
166        });
167        if (!confirmed) return;
168        clearDraft();
169    }
170    page('/project-pursuit');
171}
172
173/**
174 * Date Identified defaults to today (the page's declared default). Applied at init and again
175 * after a Reset (resetForm's onAfterReset) so "reset to their defaults" keeps its promise — CA-102.
176 */
177function applyDefaultDateIdentified(pageContainer = container) {
178    const dateIdField = pageContainer.querySelector('#pp-date-identified');
179    if (!dateIdField) return;
180    const dp = DatePicker.getInstance(dateIdField);
181    if (dp && !dp.getValue()) {
182        dp.setValue(todayISO());
183    }
184}
185
186function wireClearButton(signal) {
187    container.querySelector('#btn-pursuit-clear')?.addEventListener('click', () => handleClearForm(signal), { signal });
188}
189
190async function handleClearForm(signal) {
191    const originController = abortController;
192    const originContainer = container;
193    const originDraft = draft;
194    const cleared = await resetForm(originContainer, originDraft?.key, {
195        DatePicker,
196        SearchableDropdown,
197        signal,
198        onAfterReset: () => applyDefaultDateIdentified(originContainer)
199    });
200    if (!cleared) return;
201    if (signal?.aborted || abortController !== originController || container !== originContainer || draft !== originDraft) return;
202    originDraft?.cancelScheduledSave();
203    if (originDraft) originDraft.isDirty = false;
204    originDraft?.showStatus('Form cleared');
205}
206
207function wireSubmitButton(signal) {
208    const btn = container.querySelector('#btn-pursuit-submit');
209    if (!btn) return;
210
211    btn.addEventListener('click', () => handleSubmit(btn), { signal });
212}
213
214async function handleSubmit(btn) {
215    // CA-133: `#pp-budget` is a text input now, so this rule is the only thing between `$500` /
216    // `1,000` / `12abc` and the POST. Before the loading state and before the map.
217    // CA-146 — the rule rides `validateForm`'s own pass, so a missing required field and an
218    // unreadable budget are marked, summarised and announced in ONE submit round.
219    if (!validateForm(container, [
220        { el: container.querySelector('#pp-budget'), label: 'Estimated Budget', required: false, numeric: PURSUIT_BUDGET },
221    ])) return;
222
223    setLoading(btn, true, 'Creating...');
224
225    try {
226        const data = collectFormData();
227
228        const result = await pursuitApi.create({
229            pursuitName: data.pursuitName,
230            leadSource: data.leadSource,
231            clientName: data.clientName,
232            contactName: data.contactName || null,
233            contactEmail: data.contactEmail || null,
234            contactPhone: data.contactPhone || null,
235            dateIdentified: data.dateIdentified || null,
236            projectType: data.projectType || null,
237            // CA-133: exact `Number`, not `parseFloat`. Blank contract unchanged —
238            // `CreatePursuitRequest.EstimatedBudget` is `decimal?`, so blank is `null`, never 0.
239            estimatedBudget: data.estimatedBudget ? Number(data.estimatedBudget) : null,
240            city: data.city || null,
241            province: data.province || null,
242            assignedPrincipalId: data.assignedPrincipalId ? parseInt(data.assignedPrincipalId) : null,
243            notes: data.notes || null,
244            referredBy: data.referredBy || null,
245            projectNumber: data.projectNumber || null,
246        });
247
248        // Success — clear draft and navigate
249        if (draft) draft.isDirty = false;
250        clearDraft();
251        window.showToast?.('Pursuit created', 'success', { description: `${data.pursuitName} (${result?.internalRef || ''})` });
252
253        if (result?.pursuitId) {
254            page(`/project-pursuit/${result.pursuitId}`);
255        } else {
256            page('/project-pursuit');
257        }
258    } catch (error) {
259        if (error.name === 'AbortError') return;
260        window.showToast?.('Failed to create pursuit', 'error', { description: error.message });
261        setLoading(btn, false);
262    }
263}
264
265// ========================================
266// NAVIGATION GUARD
267// ========================================
268
269function onBeforeUnload(e) {
270    if (draft?.isDirty) {
271        draft.save();
272        e.preventDefault();
273        e.returnValue = '';
274    }
275}

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.