PageSourceSearch

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

js smrtbld.ai collected 2026-10-05 15:07:26 UTC 63,647 bytes, 1,676 lines download raw bytes

1/**
2 * project-pursuit.js — Main page module for Project Pursuit
3 *
4 * List/detail layout with 9 tab panels, collapsible sidebar,
5 * mobile dropdown navigator, filter popover, URL management.
6 *
7 * Pattern: mirrors proposals.js with hardcoded tabs (no getPageAccess)
8 */
9
10import { pursuitApi } from '../api/project-pursuits.js';
11import { todayISO } from '../utils/time-utils.js';
12import { pursuitReportApi } from '../api/pursuit-reports.js';
13import { renderSubModuleTabs } from '../utils/sub-module-icons.js';
14import { renderTo, loadTemplates } from '../utils/templates.js';
15import { afterRender } from '../utils/dom.js';
16import { downloadBlob } from '../utils/download.js';
17import { confirm, promptInput } from '../utils/modal.js';
18import { numericFailure, markError, clearError, flagFieldError } from '../utils/form-validation.js';
19import { destroyTabNav } from '../utils/tab-nav-mode.js';
20import { DropdownNavigator } from '../components/DropdownNavigator.js';
21import { BottomSheet } from '../components/BottomSheet.js';
22import { isMobile, isMobileOrTablet } from '../utils/mobile.js';
23import { initPpOverview, destroyPpOverview, OVERVIEW_PAGE_LINKS } from './pp-overview.js';
24import { initPpLeadInfo, destroyPpLeadInfo, LEAD_INFO_PAGE_LINKS } from './pp-lead-info.js';
25import { initPpClientDiscovery, destroyPpClientDiscovery, CLIENT_DISCOVERY_PAGE_LINKS } from './pp-client-discovery.js';
26import { initPpSiteEvaluation, destroyPpSiteEvaluation, SITE_EVALUATION_PAGE_LINKS } from './pp-site-evaluation.js';
27import { initPpGoNoGo, destroyPpGoNoGo, GO_NOGO_PAGE_LINKS } from './pp-go-nogo.js';
28import { initPpCompetitiveAnalysis, destroyPpCompetitiveAnalysis, COMPETITIVE_ANALYSIS_PAGE_LINKS } from './pp-competitive-analysis.js';
29import { initPpTeamConsultants, destroyPpTeamConsultants, TEAM_CONSULTANTS_PAGE_LINKS } from './pp-team-consultants.js';
30import { initPpActivityLog, destroyPpActivityLog, ACTIVITY_LOG_PAGE_LINKS } from './pp-activity-log.js';
31import { initProjectDocuments, destroyProjectDocuments, getCurrentDocumentFolderId, syncProjectDocumentsRoute, DOCUMENTS_PAGE_LINKS } from './project-documents.js';
32
33let abortController = null;
34let currentPursuitId = null;
35let pursuits = [];
36let filteredPursuits = [];
37let filterDrawerEl = null;
38let filterBackdropEl = null;
39
40// View mode: 'list' or 'kanban'
41let viewMode = 'list';
42let kanbanSortables = [];
43
44// Sub-module / collapse state
45let activeSubModule = 'overview';
46let listCollapsed = false;
47let _navigating = false;
48let pursuitTemplatesLoaded = false;
49
50// Hardcoded tab array (no getPageAccess — this is a new module)
51const PP_TABS = [
52    { id: 'overview',              label: 'Overview',              icon: 'layout-dashboard' },
53    { id: 'lead-info',             label: 'Lead Info',             icon: 'user-search' },
54    { id: 'client-discovery',      label: 'Client Discovery',      icon: 'building' },
55    { id: 'site-evaluation',       label: 'Site Evaluation',       icon: 'map-pin' },
56    { id: 'go-nogo',               label: 'Go/No-Go',             icon: 'check-circle' },
57    { id: 'competitive-analysis',  label: 'Competitive Analysis',  icon: 'bar-chart-2' },
58    { id: 'team-consultants',      label: 'Team & Consultants',    icon: 'users' },
59    { id: 'activity-log',          label: 'Activity Log',          icon: 'activity' },
60    { id: 'documents',             label: 'Documents',             icon: 'folder' },
61];
62
63// Filter state — defaults: active only, show all stages except NoGo
64let filterState = {
65    stage: ['NewLead', 'Qualifying', 'SiteVisit', 'Assessment', 'Go'],
66    status: ['active'],
67    leadSource: [],
68    projectType: [],
69    assignedTo: [],
70    scoreMin: null,
71    scoreMax: null,
72    dateFrom: null,
73    dateTo: null,
74};
75
76const DEFAULT_FILTER_STATE = () => ({
77    stage: ['NewLead', 'Qualifying', 'SiteVisit', 'Assessment', 'Go'],
78    status: ['active'],
79    leadSource: [],
80    projectType: [],
81    assignedTo: [],
82    scoreMin: null,
83    scoreMax: null,
84    dateFrom: null,
85    dateTo: null,
86});
87
88// ── Score-range filter bounds — CA-133 ──────────────────────────────────────
89// The two Score Range boxes (`pp-sub-templates.html:456`/`:458`) were `<input type="number"
90// min="0" max="100">` until this card. They are the family's only NON-saving swept controls, and
91// they were still worth gating: they feed `filterState.scoreMin`/`scoreMax`, which are compared
92// against `goNoGoScore` at :330-331. With `app.js:107-135` no longer stripping the box — both of
93// its handlers are gated on the control's native numeric type — `parseInt('abc')` produced `NaN`,
94// and because `'abc'` is truthy that NaN was STORED as the filter bound. Every comparison against
95// NaN is false, so the board silently emptied and the filter-count badge still said one filter was
96// active. Bounds come from the authored affordance (there is no column: this is client-side only);
97// the mapping is exact `Number` rather than `parseInt` because `GoNoGoScore` is `decimal?` and a
98// half-point score must be filterable.
99const SCORE_FILTER_BOUNDS = { minimum: 0, maximum: 100 };
100
101// ── Annual BD report year — CA-138 ──────────────────────────────────────────
102// The one caller of `promptInput` app-wide, and the reason MCF-0423 exists: the dialog rendered
103// `type="number"` through `modals.html`'s dynamic type expression, which no census instrument can
104// resolve, so this live native numeric business control sat outside every family row for the whole
105// arc. CA-138 maps the helper itself to `type="text" inputmode="decimal"`, which means the raw text
106// now reaches JS — `12abc`, `$500`, `1,000`, `1e999` all arrive intact instead of being blanked by
107// UA sanitization — and `parseInt` would have taken `12abc` as the year 12 and `1e999` as 1.
108// Derived from `GenerateAnnualBdReportRequest(int Year)`: a whole number, and nothing else the
109// contract states. No `minimum`/`maximum` is invented here — the report has no authored year range,
110// and a bound nobody derived is a refusal the user cannot explain. `decimalPlaces: 0` rides
111// `integer: true` for CA-127's reason: `integer` judges `Number(value)`, and `Number('0x10')` is the
112// whole number 16, so only the raw-string arm refuses hex and exponent notation.
113const BD_REPORT_YEAR = { integer: true, decimalPlaces: 0 };
114
115const LEAD_SOURCE_OPTIONS = ['Past Client', 'Referral', 'RFP/Public', 'Cold Inquiry', 'MERX', 'Competition', 'Internal', 'Other'];
116const PROJECT_TYPE_OPTIONS = ['New Construction', 'Renovation', 'Addition', 'Interior Fit-Out', 'Tenant Improvement', 'Feasibility Study', 'Master Plan', 'Adaptive Re-Use', 'Heritage Conservation', 'Infrastructure', 'Landscape', 'Mixed'];
117
118// Kanban stage column definitions
119const KANBAN_STAGES = [
120    { stage: 'NewLead',    label: 'New Lead',    badgeClass: 'badge-st-secondary' },
121    { stage: 'Qualifying', label: 'Qualifying',  badgeClass: 'badge-st-primary' },
122    { stage: 'SiteVisit',  label: 'Site Visit',  badgeClass: 'badge-st-warning' },
123    { stage: 'Assessment', label: 'Assessment',  badgeClass: 'badge-st-primary' },
124    { stage: 'Go',         label: 'Go',          badgeClass: 'badge-st-success' },
125    { stage: 'NoGo',       label: 'No-Go',       badgeClass: 'badge-st-error' },
126];
127
128// ========================================
129// PUBLIC API
130// ========================================
131
132export async function initProjectPursuitPage(selectedId = null, subModule = null, subTab = null) {
133    abortController = new AbortController();
134
135    if (!pursuitTemplatesLoaded) {
136        await loadTemplates('components/pp-sub-templates');
137        pursuitTemplatesLoaded = true;
138    }
139
140    // Reset state
141    currentPursuitId = null;
142    pursuits = [];
143    filteredPursuits = [];
144    filterState = DEFAULT_FILTER_STATE();
145    filterDrawerEl = null;
146    filterBackdropEl = null;
147    viewMode = 'list';
148    kanbanSortables = [];
149    activeSubModule = 'overview';
150    listCollapsed = false;
151
152    const pendingSubModule = subModule;
153    const pendingSubTab = subTab;
154
155    // Initialize Event Handlers
156    initListHandlers();
157    initMobileBackHandler();
158    initDropdownNavigator();
159    initToggleHandler();
160    initSubModuleTabHandlers();
161    initPursuitUpdatedListener();
162    initViewToggle();
163    initOverviewFilterListeners();
164
165    // Load pursuit list
166    await loadPursuits();
167
168    // Auto-select pursuit from URL param (suppress pushState — URL already correct from route)
169    _navigating = true;
170    if (selectedId) {
171        await selectPursuit(selectedId);
172
173        if (pendingSubModule && pendingSubModule !== 'overview') {
174            switchSubModule(pendingSubModule, subTab);
175        }
176    }
177    _navigating = false;
178}
179
180export function destroyProjectPursuitPage() {
181    destroyTabNav(document.getElementById('sub-module-tabs'));
182    destroyPpOverview();
183    destroyPpLeadInfo();
184    destroyPpClientDiscovery();
185    destroyPpSiteEvaluation();
186    destroyPpGoNoGo();
187    destroyPpCompetitiveAnalysis();
188    destroyPpTeamConsultants();
189    destroyPpActivityLog();
190    destroyProjectDocuments();
191    DropdownNavigator.destroy(DROPDOWN_NAV_ID);
192    destroyKanbanSortable();
193    closeFilterDrawer();
194    abortController?.abort();
195    abortController = null;
196    currentPursuitId = null;
197    pursuits = [];
198    filteredPursuits = [];
199    filterDrawerEl = null;
200    filterBackdropEl = null;
201    filterState = DEFAULT_FILTER_STATE();
202    viewMode = 'list';
203    kanbanSortables = [];
204    activeSubModule = 'overview';
205    listCollapsed = false;
206    _navigating = false;
207}
208
209/**
210 * Lightweight in-page navigation (called by route guard on same-page back/forward).
211 * Reuses existing DOM, event handlers, and cached data — no full re-render.
212 * Sets _navigating flag to prevent URL pushes (URL is already correct from popstate).
213 */
214export async function navigateProjectPursuitPage(id = null, subModule = null, subTab = null) {
215    _navigating = true;
216    try {
217        if (id && id !== currentPursuitId) {
218            const target = subModule || 'overview';
219            await selectPursuit(id);
220            if (target !== 'overview') await switchSubModule(target, subTab);
221            if (DOCUMENTS_PAGE_LINKS.includes(target)) await syncProjectDocumentsRoute(subTab);
222        } else if (id && id === currentPursuitId) {
223            const target = subModule || 'overview';
224            if (target !== activeSubModule) switchSubModule(target, subTab);
225            else if (DOCUMENTS_PAGE_LINKS.includes(target)) await syncProjectDocumentsRoute(subTab);
226        } else if (!id && currentPursuitId) {
227            showMobileList();
228        }
229    } finally {
230        _navigating = false;
231    }
232}
233
234// ========================================
235// LIST LOGIC
236// ========================================
237
238async function loadPursuits() {
239    const listContainer = document.getElementById('pursuits-list');
240    if (!listContainer) return;
241
242    try {
243        const response = await pursuitApi.getAll();
244        pursuits = response.pursuits || response || [];
245
246        applyFilters();
247        renderPursuitList();
248    } catch (error) {
249        if (error.name === 'AbortError') return;
250
251        // Expected 404 in Phase 0 (no backend yet) — show empty state
252        pursuits = [];
253        applyFilters();
254        renderPursuitList();
255    }
256}
257
258async function renderPursuitList() {
259    const listContainer = document.getElementById('pursuits-list');
260    const countEl = document.getElementById('pursuit-count');
261    if (!listContainer) return;
262
263    // Render CCM banner
264    renderCcmBanner();
265
266    // Kanban mode
267    if (viewMode === 'kanban') {
268        renderKanbanBoard();
269        if (countEl) countEl.textContent = `${filteredPursuits.length} pursuit${filteredPursuits.length !== 1 ? 's' : ''}`;
270        syncDropdownNavigator();
271        return;
272    }
273
274    // List mode
275    if (filteredPursuits.length === 0) {
276        await renderTo('#pursuits-list', '#tpl-pp-empty', {});
277    } else {
278        await renderTo('#pursuits-list', '#tpl-pp-list-row', filteredPursuits);
279
280        // Handle selection state
281        if (currentPursuitId) {
282            const row = listContainer.querySelector(`[data-id="${currentPursuitId}"]`);
283            if (row) row.classList.add('border-st-primary', 'bg-st-primary/5');
284        }
285    }
286
287    if (countEl) countEl.textContent = `${filteredPursuits.length} pursuit${filteredPursuits.length !== 1 ? 's' : ''}`;
288
289    // Keep mini list in sync if collapsed
290    if (listCollapsed) {
291        renderMiniPursuitList();
292    }
293
294    // Sync mobile dropdown navigator
295    syncDropdownNavigator();
296}
297
298function initListHandlers() {
299    const listContainer = document.getElementById('pursuits-list');
300    const searchInput = document.getElementById('pursuit-search');
301    const filterBtn = document.getElementById('btn-filter');
302    const addBtn = document.getElementById('btn-create-pursuit');
303
304    listContainer?.addEventListener('click', (e) => {
305        const row = e.target.closest('.pursuit-row');
306        if (row) {
307            const id = parseInt(row.dataset.id);
308            selectPursuit(id);
309        }
310        if (e.target.closest('[data-action="create-pursuit"]')) {
311            handleCreatePursuit();
312        }
313    }, { signal: abortController.signal });
314
315    searchInput?.addEventListener('input', (e) => {
316        applyFilters(e.target.value);
317        renderPursuitList();
318    }, { signal: abortController.signal });
319
320    filterBtn?.addEventListener('click', () => toggleFilterDrawer(), { signal: abortController.signal });
321
322    addBtn?.addEventListener('click', () => handleCreatePursuit(), { signal: abortController.signal });
323}
324
325function applyFilters(searchTerm = '') {
326    const term = (searchTerm || '').toLowerCase();
327    filteredPursuits = pursuits.filter(p => {
328        // Text search
329        const matchesSearch = !term ||
330            (p.opportunityName && p.opportunityName.toLowerCase().includes(term)) ||
331            (p.clientName && p.clientName.toLowerCase().includes(term)) ||
332            (p.locationCity && p.locationCity.toLowerCase().includes(term)) ||
333            (p.projectType && p.projectType.toLowerCase().includes(term));
334
335        // Stage filter
336        const matchesStage = !p.stage || filterState.stage.includes(p.stage);
337
338        // Status filter
339        const isActive = !p.isDeleted;
340        const matchesStatus =
341            (isActive && filterState.status.includes('active')) ||
342            (!isActive && filterState.status.includes('archived'));
343
344        // Lead Source filter
345        const matchesSource = filterState.leadSource.length === 0 ||
346            filterState.leadSource.includes(p.leadSource);
347
348        // Project Type filter
349        const matchesType = filterState.projectType.length === 0 ||
350            filterState.projectType.includes(p.projectType);
351
352        // Assigned To filter
353        const matchesPrincipal = filterState.assignedTo.length === 0 ||
354            filterState.assignedTo.includes(p.assignedPrincipalName);
355
356        // Score Range
357        const matchesScore =
358            (filterState.scoreMin == null || (p.goNoGoScore != null && p.goNoGoScore >= filterState.scoreMin)) &&
359            (filterState.scoreMax == null || (p.goNoGoScore != null && p.goNoGoScore <= filterState.scoreMax));
360
361        // Date Range
362        const matchesDate =
363            (!filterState.dateFrom || new Date(p.dateIdentified) >= new Date(filterState.dateFrom)) &&
364            (!filterState.dateTo || new Date(p.dateIdentified) <= new Date(filterState.dateTo));
365
366        return matchesSearch && matchesStage && matchesStatus &&
367            matchesSource && matchesType && matchesPrincipal &&
368            matchesScore && matchesDate;
369    });
370}
371
372// ========================================
373// FILTER DRAWER
374// ========================================
375
376function getUniquePrincipals() {
377    const names = new Set();
378    for (const p of pursuits) {
379        if (p.assignedPrincipalName) names.add(p.assignedPrincipalName);
380    }
381    return [...names].sort();
382}
383
384function getActiveFilterCount() {
385    let count = 0;
386    const d = DEFAULT_FILTER_STATE();
387    if (filterState.stage.length !== d.stage.length || !d.stage.every(s => filterState.stage.includes(s))) count++;
388    if (filterState.status.length !== d.status.length || !d.status.every(s => filterState.status.includes(s))) count++;
389    if (filterState.leadSource.length > 0) count++;
390    if (filterState.projectType.length > 0) count++;
391    if (filterState.assignedTo.length > 0) count++;
392    if (filterState.scoreMin != null) count++;
393    if (filterState.scoreMax != null) count++;
394    if (filterState.dateFrom) count++;
395    if (filterState.dateTo) count++;
396    return count;
397}
398
399function toggleFilterDrawer() {
400    if (filterDrawerEl) {
401        closeFilterDrawer();
402        return;
403    }
404
405    // On mobile, use BottomSheet instead of drawer
406    if (isMobile()) {
407        openFilterBottomSheet();
408        return;
409    }
410
411    openFilterDrawer();
412}
413
414function openFilterDrawer() {
415    const principalOptions = getUniquePrincipals();
416
417    const opts = abortController?.signal ? { signal: abortController.signal } : {};
418
419    // Backdrop
420    filterBackdropEl = document.createElement('div');
421    filterBackdropEl.className = 'filter-drawer-backdrop';
422    filterBackdropEl.addEventListener('click', closeFilterDrawer, opts);
423    document.body.appendChild(filterBackdropEl);
424
425    // Drawer
426    filterDrawerEl = document.createElement('div');
427    filterDrawerEl.className = 'filter-drawer-st';
428
429    // Header via template
430    const headerTpl = $.templates('#tpl-pp-filter-drawer-header');
431    filterDrawerEl.innerHTML = headerTpl.render({});
432
433    document.body.appendChild(filterDrawerEl);
434
435    // Render filter content
436    const tpl = $.templates('#tpl-pp-filter-panel');
437    const contentEl = filterDrawerEl.querySelector('#filter-panel-content');
438    contentEl.innerHTML = tpl.render({}, buildFilterHelpers(principalOptions));
439
440    // Animate open
441    requestAnimationFrame(() => filterDrawerEl.classList.add('open'));
442
443    // Wire events
444    wireFilterEvents(filterDrawerEl);
445
446    // Close button
447    filterDrawerEl.querySelector('[data-action="close-filter"]')?.addEventListener('click', closeFilterDrawer, opts);
448
449    afterRender(filterDrawerEl);
450}
451
452function openFilterBottomSheet() {
453    // On mobile: use the same drawer but styled as bottom sheet via CSS class
454    const principalOptions = getUniquePrincipals();
455
456    const opts = abortController?.signal ? { signal: abortController.signal } : {};
457
458    filterBackdropEl = document.createElement('div');
459    filterBackdropEl.className = 'filter-drawer-backdrop';
460    filterBackdropEl.addEventListener('click', closeFilterDrawer, opts);
461    document.body.appendChild(filterBackdropEl);
462
463    filterDrawerEl = document.createElement('div');
464    filterDrawerEl.className = 'filter-drawer-st mobile';
465
466    const headerTpl = $.templates('#tpl-pp-filter-drawer-header');
467    filterDrawerEl.innerHTML = headerTpl.render({});
468
469    document.body.appendChild(filterDrawerEl);
470
471    const tpl = $.templates('#tpl-pp-filter-panel');
472    const contentEl = filterDrawerEl.querySelector('#filter-panel-content');
473    contentEl.innerHTML = tpl.render({}, buildFilterHelpers(principalOptions));
474
475    // Animate: slide up
476    requestAnimationFrame(() => filterDrawerEl.classList.add('open'));
477
478    wireFilterEvents(filterDrawerEl);
479    filterDrawerEl.querySelector('[data-action="close-filter"]')?.addEventListener('click', closeFilterDrawer, opts);
480
481    afterRender(filterDrawerEl);
482}
483
484function buildFilterHelpers(principalOptions) {
485    return {
486        stageNewLead: filterState.stage.includes('NewLead'),
487        stageQualifying: filterState.stage.includes('Qualifying'),
488        stageSiteVisit: filterState.stage.includes('SiteVisit'),
489        stageAssessment: filterState.stage.includes('Assessment'),
490        stageGo: filterState.stage.includes('Go'),
491        stageNoGo: filterState.stage.includes('NoGo'),
492        statusActive: filterState.status.includes('active'),
493        statusArchived: filterState.status.includes('archived'),
494        leadSourceOptions: LEAD_SOURCE_OPTIONS,
495        projectTypeOptions: PROJECT_TYPE_OPTIONS,
496        principalOptions: principalOptions,
497        scoreMin: filterState.scoreMin ?? '',
498        scoreMax: filterState.scoreMax ?? '',
499        dateFrom: filterState.dateFrom || '',
500        dateTo: filterState.dateTo || '',
501        activeFilterCount: getActiveFilterCount(),
502        isChecked(group, value) {
503            return filterState[group]?.includes(value) || false;
504        }
505    };
506}
507
508function wireFilterEvents(container) {
509    const opts = abortController?.signal ? { signal: abortController.signal } : {};
510    // Checkbox filters
511    container.querySelectorAll('input[type="checkbox"][data-filter]').forEach(cb => {
512        cb.addEventListener('change', () => {
513            const group = cb.dataset.filter;
514            const value = cb.value;
515
516            if (!filterState[group]) return;
517
518            if (cb.checked) {
519                if (!filterState[group].includes(value)) filterState[group].push(value);
520            } else {
521                filterState[group] = filterState[group].filter(v => v !== value);
522            }
523
524            onFilterChanged();
525        }, opts);
526    });
527
528    // Score range inputs — CA-133 gate, see SCORE_FILTER_BOUNDS above
529    container.querySelector('[data-filter="scoreMin"]')?.addEventListener('change', (e) => {
530        if (!applyScoreFilter(e.target, 'scoreMin')) return;
531        onFilterChanged();
532    });
533    container.querySelector('[data-filter="scoreMax"]')?.addEventListener('change', (e) => {
534        if (!applyScoreFilter(e.target, 'scoreMax')) return;
535        onFilterChanged();
536    });
537
538    // Date range inputs
539    container.querySelector('[data-filter="dateFrom"]')?.addEventListener('change', (e) => {
540        filterState.dateFrom = e.target.value || null;
541        onFilterChanged();
542    });
543    container.querySelector('[data-filter="dateTo"]')?.addEventListener('change', (e) => {
544        filterState.dateTo = e.target.value || null;
545        onFilterChanged();
546    });
547
548    // Reset button
549    container.querySelector('[data-action="reset-filters"]')?.addEventListener('click', () => {
550        filterState = DEFAULT_FILTER_STATE();
551        closeFilterDrawer();
552        onFilterChanged();
553    });
554}
555
556/**
557 * Read one Score Range box into `filterState`, refusing a value that would poison the filter — CA-133.
558 *
559 * Marks the box and says why rather than silently emptying the board. Returns false when nothing
560 * changed, so the caller skips the re-filter and the previous bound stays in force.
561 *
562 * @param {HTMLInputElement} input
563 * @param {'scoreMin'|'scoreMax'} key
564 * @returns {boolean} true when `filterState[key]` was updated
565 */
566function applyScoreFilter(input, key) {
567    const raw = input.value.trim();
568    // `filterDrawerEl` is the live drawer for BOTH variants — desktop (:411) and the mobile
569    // bottom-sheet clone (:448) — so the mark and its clear always share one scope.
570    const container = filterDrawerEl || input.parentElement || document.body;
571
572    // Blank clears the bound — that is this control's blank contract and it is unchanged.
573    if (raw === '') {
574        clearError(container, input);
575        filterState[key] = null;
576        return true;
577    }
578
579    // The sentence comes from the shared composer, not from here. Hand-writing it — as this did
580    // until the [functionality:1] census reached it — produced ONE message for two different
581    // failures, so typing "abc" was told it "must be between 0 and 100", which names a range
582    // problem the user does not have. `numericFailure` distinguishes them (`is not a valid number`
583    // vs `must be between …`) and is the same composer every other refusal in this card uses:
584    // one app, one sentence (CA-125 [ux:5]).
585    const failure = numericFailure('Score', raw, SCORE_FILTER_BOUNDS);
586    if (failure) {
587        markError(container, input, failure);
588        window.showToast?.(failure, 'error');
589        return false;
590    }
591
592    clearError(container, input);
593    filterState[key] = Number(raw);
594    return true;
595}
596
597function onFilterChanged() {
598    const searchInput = document.getElementById('pursuit-search');
599    applyFilters(searchInput?.value || '');
600    renderPursuitList();
601    updateFilterIndicator();
602}
603
604function closeFilterDrawer() {
605    if (filterDrawerEl) {
606        // Animate close — CSS handles both desktop (translateX) and mobile (translateY)
607        filterDrawerEl.classList.remove('open');
608        const el = filterDrawerEl;
609        setTimeout(() => {
610            el?.remove();
611        }, 250);
612        filterDrawerEl = null;
613    }
614    if (filterBackdropEl) {
615        filterBackdropEl.remove();
616        filterBackdropEl = null;
617    }
618}
619
620function isFilterNonDefault() {
621    return getActiveFilterCount() > 0;
622}
623
624function updateFilterIndicator() {
625    const indicator = document.getElementById('filter-indicator');
626    if (indicator) {
627        indicator.classList.toggle('hidden', !isFilterNonDefault());
628    }
629}
630
631// ========================================
632// OVERVIEW FILTER EVENT LISTENERS
633// ========================================
634
635function initOverviewFilterListeners() {
636    const signal = abortController?.signal;
637
638    // Funnel stage click → filter to that stage
639    document.addEventListener('pursuit:filter-stage', (e) => {
640        const stage = e.detail?.stage;
641        if (!stage) return;
642
643        // Set filter to show only this stage, active status
644        filterState = DEFAULT_FILTER_STATE();
645        filterState.stage = [stage];
646        if (stage === 'NoGo') {
647            // NoGo isn't in the default — make sure we show it
648            filterState.status = ['active', 'archived'];
649        }
650        onFilterChanged();
651
652        // Switch to list view if on kanban
653        if (viewMode !== 'list') {
654            const listBtn = document.querySelector('[data-view="list"]');
655            if (listBtn) listBtn.click();
656        }
657    }, { signal });
658
659    // Client name click → filter by client name (text search)
660    document.addEventListener('pursuit:filter-client', (e) => {
661        const name = e.detail?.clientName;
662        if (!name) return;
663
664        // Reset filters and use text search
665        filterState = DEFAULT_FILTER_STATE();
666        filterState.stage = ['NewLead', 'Qualifying', 'SiteVisit', 'Assessment', 'Go', 'NoGo'];
667        filterState.status = ['active', 'archived'];
668
669        const searchInput = document.getElementById('pursuit-search');
670        if (searchInput) searchInput.value = name;
671        applyFilters(name);
672        renderPursuitList();
673        updateFilterIndicator();
674
675        // Switch to list view if on kanban
676        if (viewMode !== 'list') {
677            const listBtn = document.querySelector('[data-view="list"]');
678            if (listBtn) listBtn.click();
679        }
680    }, { signal });
681}
682
683// ========================================
684// CCM BANNER
685// ========================================
686
687function computeCcmCounts() {
688    const now = new Date();
689    now.setHours(0, 0, 0, 0);
690    const in7d = new Date(now);
691    in7d.setDate(in7d.getDate() + 7);
692
693    let staleCount = 0, agingCount = 0, deadlineCount = 0;
694
695    for (const p of filteredPursuits) {
696        if (p.isDeleted) continue;
697        if (p.daysInStage > 30) staleCount++;
698        else if (p.daysInStage > 14) agingCount++;
699        if (p.deadlineToDecide) {
700            const dd = new Date(p.deadlineToDecide);
701            dd.setHours(0, 0, 0, 0);
702            if (dd >= now && dd <= in7d) deadlineCount++;
703        }
704    }
705    return { staleCount, agingCount, deadlineCount };
706}
707
708function renderCcmBanner() {
709    const bannerEl = document.getElementById('pp-ccm-banner');
710    if (!bannerEl) return;
711
712    const counts = computeCcmCounts();
713    const tpl = $.templates('#tpl-pp-ccm-banner');
714    if (tpl) {
715        bannerEl.innerHTML = tpl.render(counts);
716        afterRender(bannerEl);
717    }
718}
719
720// ========================================
721// VIEW TOGGLE + EXPORT
722// ========================================
723
724function initViewToggle() {
725    const signal = abortController.signal;
726    document.getElementById('btn-view-list')?.addEventListener('click', () => switchView('list'), { signal });
727    document.getElementById('btn-view-kanban')?.addEventListener('click', () => switchView('kanban'), { signal });
728    document.getElementById('btn-export-csv')?.addEventListener('click', () => exportCsv(), { signal });
729    initReportHandlers(signal);
730}
731
732// ========================================
733// REPORT GENERATION
734// ========================================
735
736function initReportHandlers(signal) {
737    // Reports dropdown toggle
738    const dropdownBtn = document.getElementById('btn-reports-dropdown');
739    const dropdownMenu = document.getElementById('pp-reports-menu');
740    if (dropdownBtn && dropdownMenu) {
741        dropdownBtn.addEventListener('click', (e) => {
742            e.stopPropagation();
743            dropdownMenu.classList.toggle('hidden');
744        }, { signal });
745
746        // Close on outside click
747        document.addEventListener('click', () => dropdownMenu.classList.add('hidden'), { signal });
748    }
749
750    // Delegated click for all .pp-report-btn (works for both list dropdown and detail action menu)
751    document.addEventListener('click', (e) => {
752        const btn = e.target.closest('.pp-report-btn');
753        if (!btn) return;
754        const reportType = btn.dataset.report;
755        if (reportType) generateReport(reportType);
756        // Close dropdown if open
757        dropdownMenu?.classList.add('hidden');
758    }, { signal });
759}
760
761async function generateReport(reportType) {
762    try {
763        let response;
764        switch (reportType) {
765            case 'assessment':
766                if (!currentPursuitId) { window.showToast?.('Select a pursuit first', 'info'); return; }
767                response = await pursuitReportApi.generateAssessment(currentPursuitId);
768                break;
769            case 'site-evaluation':
770                if (!currentPursuitId) { window.showToast?.('Select a pursuit first', 'info'); return; }
771                response = await pursuitReportApi.generateSiteEvaluation(currentPursuitId);
772                break;
773            case 'summary':
774                if (!currentPursuitId) { window.showToast?.('Select a pursuit first', 'info'); return; }
775                response = await pursuitReportApi.generateSummary(currentPursuitId);
776                break;
777            case 'pipeline':
778                response = await pursuitReportApi.generatePipeline({
779                    stageFilter: filterState.stage.length < 6 ? filterState.stage.join(',') : null,
780                    statusFilter: filterState.status.length === 1 ? filterState.status[0] : null,
781                });
782                break;
783            case 'annual-bd': {
784                const year = await promptInput({
785                    title: 'Annual BD Report',
786                    message: 'Enter the year for the BD report.',
787                    icon: 'file-text',
788                    inputType: 'number',
789                    // Equal BY CONSTRUCTION to the label the rule below composes its sentence
790                    // from: one string, named once. A refusal that says "Year is not a valid
791                    // number" about an unlabelled box is a sentence with nothing to attach to,
792                    // and it is the shape this card's own Bible clause forbids in terms.
793                    inputLabel: 'Year',
794                    placeholder: 'YYYY',
795                    defaultValue: new Date().getFullYear().toString(),
796                    confirmText: 'Generate',
797                    // Refused IN THE DIALOG, not after it closes: the commit gate keeps the modal
798                    // open with the typed year on screen, marks the control and states the failure
799                    // in the shared CA-125 sentence, so a mistyped year is corrected in place. Blank
800                    // is not the predicate's business (Dan, 2026-08-28, constraint 4) — it falls
801                    // through to the `if (!year) return` below, exactly as cancel always has.
802                    validate: (value, inputEl, container) => {
803                        if (!value) return null;
804                        const failure = numericFailure('Year', value, BD_REPORT_YEAR);
805                        if (!failure) return null;
806                        flagFieldError(container, inputEl, failure, {
807                            rule: { label: 'Year', required: false, numeric: BD_REPORT_YEAR },
808                        });
809                        return failure;
810                    },
811                });
812                if (!year) return;
813                // Exact `Number`, never `parseInt`: `parseInt` reads a valid prefix and discards the
814                // rest, which is the launder the gate above now makes unreachable — and keeping it
815                // would leave the file one edit away from re-opening it.
816                response = await pursuitReportApi.generateAnnualBd(Number(year));
817                break;
818            }
819            default:
820                return;
821        }
822
823        if (response?.jobId) {
824            window.showToast?.('Report queued', 'success', {
825                description: "You'll be notified when it's ready for download."
826            });
827        }
828    } catch (error) {
829        if (error.name === 'AbortError') return;
830        window.showToast?.('Failed to generate report', 'error', { description: error.message });
831    }
832}
833
834function switchView(mode) {
835    if (mode === viewMode) return;
836    viewMode = mode;
837
838    const listBtn = document.getElementById('btn-view-list');
839    const kanbanBtn = document.getElementById('btn-view-kanban');
840
841    if (listBtn) {
842        listBtn.classList.toggle('bg-st-primary/10', mode === 'list');
843        listBtn.classList.toggle('text-st-primary', mode === 'list');
844        listBtn.classList.toggle('text-theme-secondary', mode !== 'list');
845    }
846    if (kanbanBtn) {
847        kanbanBtn.classList.toggle('bg-st-primary/10', mode === 'kanban');
848        kanbanBtn.classList.toggle('text-st-primary', mode === 'kanban');
849        kanbanBtn.classList.toggle('text-theme-secondary', mode !== 'kanban');
850    }
851
852    if (mode === 'list') {
853        destroyKanbanSortable();
854        renderPursuitList();
855    } else {
856        renderPursuitList(); // Will branch to renderKanbanBoard internally
857    }
858}
859
860function exportCsv() {
861    if (filteredPursuits.length === 0) {
862        window.showToast?.('No data to export', 'info');
863        return;
864    }
865
866    const headers = ['Name', 'Client', 'Stage', 'Lead Source', 'Project Type', 'Budget', 'Score', 'Decision', 'Days in Stage', 'Last Activity', 'Date Identified', 'City', 'Province', 'Principal'];
867
868    const escCsv = (val) => {
869        if (val == null) return '';
870        const str = String(val);
871        if (str.includes(',') || str.includes('"') || str.includes('\n')) {
872            return '"' + str.replace(/"/g, '""') + '"';
873        }
874        return str;
875    };
876
877    const rows = filteredPursuits.map(p => [
878        escCsv(p.opportunityName),
879        escCsv(p.clientName),
880        escCsv(p.stage),
881        escCsv(p.leadSource),
882        escCsv(p.projectType),
883        escCsv(p.estimatedBudget),
884        escCsv(p.goNoGoScore),
885        escCsv(p.goNoGoDecision),
886        escCsv(p.daysInStage),
887        escCsv(p.lastActivityDate),
888        escCsv(p.dateIdentified),
889        escCsv(p.locationCity),
890        escCsv(p.locationProvince),
891        escCsv(p.assignedPrincipalName),
892    ].join(','));
893
894    const csv = [headers.join(','), ...rows].join('\n');
895    const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
896    downloadBlob(blob, `pursuits-${todayISO()}.csv`);
897    window.showToast?.('CSV exported', 'success', { description: `${filteredPursuits.length} pursuits` });
898}
899
900// ========================================
901// KANBAN BOARD
902// ========================================
903
904function renderKanbanBoard() {
905    const listContainer = document.getElementById('pursuits-list');
906    if (!listContainer) return;
907
908    destroyKanbanSortable();
909
910    // Group pursuits by stage
911    const grouped = {};
912    for (const s of KANBAN_STAGES) {
913        grouped[s.stage] = [];
914    }
915    for (const p of filteredPursuits) {
916        if (grouped[p.stage]) {
917            grouped[p.stage].push(p);
918        }
919    }
920
921    // Build column data
922    const columns = KANBAN_STAGES.map(s => ({
923        ...s,
924        count: grouped[s.stage].length,
925    }));
926
927    // Render board
928    const tpl = $.templates('#tpl-pp-kanban-board');
929    if (tpl) {
930        listContainer.innerHTML = tpl.render({ columns });
931    }
932
933    // Render cards into each drop zone
934    const cardTpl = $.templates('#tpl-pp-kanban-card');
935    if (cardTpl) {
936        listContainer.querySelectorAll('.pp-kanban-drop-zone').forEach(zone => {
937            const stage = zone.dataset.stage;
938            const cards = grouped[stage] || [];
939            zone.innerHTML = cards.map(c => cardTpl.render(c)).join('');
940        });
941    }
942
943    // Init Sortable drag-and-drop
944    initKanbanSortable();
945
946    // Wire card clicks
947    const opts = abortController?.signal ? { signal: abortController.signal } : {};
948    listContainer.querySelectorAll('.kanban-card').forEach(card => {
949        card.addEventListener('click', () => {
950            const id = parseInt(card.dataset.id);
951            selectPursuit(id);
952        }, opts);
953    });
954
955    afterRender(listContainer);
956}
957
958function initKanbanSortable() {
959    if (typeof Sortable === 'undefined') return;
960
961    document.querySelectorAll('.pp-kanban-drop-zone').forEach(zone => {
962        const sortable = Sortable.create(zone, {
963            group: 'pursuit-kanban',
964            animation: 150,
965            ghostClass: 'opacity-30',
966            dragClass: 'shadow-sb-lg',
967            onEnd: handleKanbanDrop,
968        });
969        kanbanSortables.push(sortable);
970    });
971}
972
973function destroyKanbanSortable() {
974    kanbanSortables.forEach(s => s.destroy());
975    kanbanSortables = [];
976}
977
978async function handleKanbanDrop(evt) {
979    const cardEl = evt.item;
980    const pursuitId = parseInt(cardEl.dataset.id);
981    const newStage = evt.to.dataset.stage;
982    const oldStage = evt.from.dataset.stage;
983
984    if (newStage === oldStage) return;
985
986    // Confirm for Go/NoGo transitions
987    if (newStage === 'Go' || newStage === 'NoGo') {
988        const label = newStage === 'Go' ? 'Go' : 'No-Go';
989        const confirmed = await confirm({
990            title: `Move to ${label}?`,
991            message: `This will change the pursuit stage to "${label}". This is a significant decision.`,
992            icon: newStage === 'Go' ? 'check-circle' : 'x-circle',
993            confirmText: `Move to ${label}`,
994            confirmClass: newStage === 'Go' ? 'btn-st-primary' : 'btn-st-error',
995        });
996
997        if (!confirmed) {
998            // Revert: re-render kanban to original positions
999            renderKanbanBoard();
1000            return;
1001        }
1002    }
1003
1004    try {
1005        await pursuitApi.changeStage(pursuitId, { newStage });
1006
1007        // Update local data
1008        const pursuit = pursuits.find(p => p.pursuitID === pursuitId);
1009        if (pursuit) {
1010            pursuit.stage = newStage;
1011            pursuit.daysInStage = 0;
1012        }
1013
1014        // Update column counts
1015        updateKanbanCounts();
1016
1017        // Dispatch event for detail pane refresh
1018        document.dispatchEvent(new CustomEvent('pursuit:updated', { detail: { pursuitId } }));
1019
1020        window.showToast?.('Stage updated', 'success', { description: `Moved to ${newStage}` });
1021    } catch (error) {
1022        if (error.name === 'AbortError') return;
1023        window.showToast?.('Failed to change stage', 'error', { description: error.message });
1024        // Revert
1025        renderKanbanBoard();
1026    }
1027}
1028
1029function updateKanbanCounts() {
1030    document.querySelectorAll('.pp-kanban-drop-zone').forEach(zone => {
1031        const count = zone.children.length;
1032        const header = zone.closest('.pp-kanban-column')?.querySelector('.text-2xs.font-semibold');
1033        if (header) header.textContent = count;
1034    });
1035}
1036
1037// ========================================
1038// URL MANAGEMENT
1039// ========================================
1040
1041function buildUrl() {
1042    const base = '/project-pursuit';
1043    if (!currentPursuitId) return base;
1044    let url = `${base}/${currentPursuitId}`;
1045
1046    if (activeSubModule && activeSubModule !== 'overview') {
1047        url += `/${activeSubModule}`;
1048        if (DOCUMENTS_PAGE_LINKS.includes(activeSubModule)) {
1049            // Documents deep-links the open FOLDER (0 / root → no segment).
1050            const folderId = getCurrentDocumentFolderId();
1051            if (folderId) url += `/${folderId}`;
1052        }
1053    }
1054    return url;
1055}
1056
1057function updateUrl(push = false) {
1058    const url = buildUrl();
1059    if (push && !_navigating) {
1060        page.show(url);
1061    } else {
1062        history.replaceState(null, '', url);
1063    }
1064}
1065
1066// ========================================
1067// SUB-MODULE TABS
1068// ========================================
1069
1070function initSubModuleTabHandlers() {
1071    const tabContainer = document.getElementById('sub-module-tabs');
1072    if (!tabContainer) return;
1073
1074    tabContainer.addEventListener('click', (e) => {
1075        const btn = e.target.closest('[data-tab]');
1076        if (!btn || btn.classList.contains('tab-nav-mode-toggle') || btn.classList.contains('tab-overflow-more') || btn.classList.contains('tab-overflow-item')) return;
1077        switchSubModule(btn.dataset.tab);
1078    }, { signal: abortController.signal });
1079}
1080
1081function initPursuitUpdatedListener() {
1082    document.addEventListener('pursuit:updated', async (e) => {
1083        const id = e.detail?.pursuitId;
1084        if (!id || id !== currentPursuitId) return;
1085
1086        try {
1087            const response = await pursuitApi.getById(id);
1088            const pursuit = response?.pursuit || response;
1089
1090            // Update list item
1091            const idx = pursuits.findIndex(p => p.pursuitID === id);
1092            if (idx >= 0) {
1093                pursuits[idx] = { ...pursuits[idx], ...pursuit };
1094                applyFilters(document.getElementById('pursuit-search')?.value || '');
1095                renderPursuitList();
1096            }
1097
1098            // Refresh header
1099            await renderPursuitDetail(pursuit);
1100        } catch (error) {
1101            if (error.name === 'AbortError') return;
1102            console.warn('[Pursuit] Failed to refresh after update:', error.message);
1103        }
1104    }, { signal: abortController.signal });
1105}
1106
1107function switchSubModule(moduleId, subTab = null) {
1108    let documentsInitPromise = null;
1109    if (moduleId === activeSubModule) return;
1110
1111    // Clean up previous sub-module state
1112    if (OVERVIEW_PAGE_LINKS.includes(activeSubModule) && !OVERVIEW_PAGE_LINKS.includes(moduleId)) {
1113        destroyPpOverview();
1114    }
1115    if (LEAD_INFO_PAGE_LINKS.includes(activeSubModule) && !LEAD_INFO_PAGE_LINKS.includes(moduleId)) {
1116        destroyPpLeadInfo();
1117    }
1118    if (CLIENT_DISCOVERY_PAGE_LINKS.includes(activeSubModule) && !CLIENT_DISCOVERY_PAGE_LINKS.includes(moduleId)) {
1119        destroyPpClientDiscovery();
1120    }
1121    if (SITE_EVALUATION_PAGE_LINKS.includes(activeSubModule) && !SITE_EVALUATION_PAGE_LINKS.includes(moduleId)) {
1122        destroyPpSiteEvaluation();
1123    }
1124    if (GO_NOGO_PAGE_LINKS.includes(activeSubModule) && !GO_NOGO_PAGE_LINKS.includes(moduleId)) {
1125        destroyPpGoNoGo();
1126    }
1127    if (COMPETITIVE_ANALYSIS_PAGE_LINKS.includes(activeSubModule) && !COMPETITIVE_ANALYSIS_PAGE_LINKS.includes(moduleId)) {
1128        destroyPpCompetitiveAnalysis();
1129    }
1130    if (TEAM_CONSULTANTS_PAGE_LINKS.includes(activeSubModule) && !TEAM_CONSULTANTS_PAGE_LINKS.includes(moduleId)) {
1131        destroyPpTeamConsultants();
1132    }
1133    if (ACTIVITY_LOG_PAGE_LINKS.includes(activeSubModule) && !ACTIVITY_LOG_PAGE_LINKS.includes(moduleId)) {
1134        destroyPpActivityLog();
1135    }
1136    if (DOCUMENTS_PAGE_LINKS.includes(activeSubModule) && !DOCUMENTS_PAGE_LINKS.includes(moduleId)) {
1137        destroyProjectDocuments();
1138    }
1139
1140    activeSubModule = moduleId;
1141
1142    // Toggle header compact/expanded based on active tab
1143    const headerEl = document.getElementById('detail-header');
1144    if (headerEl) {
1145        headerEl.classList.toggle('header-compact', moduleId !== 'overview');
1146    }
1147
1148    // Update tab active state
1149    const tabContainer = document.getElementById('sub-module-tabs');
1150    tabContainer?.querySelectorAll('.pp-tab-btn').forEach(btn => {
1151        const on = btn.dataset.tab === moduleId;
1152        btn.classList.toggle('active', on);
1153        btn.setAttribute('aria-selected', String(on));
1154    });
1155
1156    // Hide all tab panels
1157    const panelIds = [
1158        'tab-pp-overview', 'tab-pp-lead-info', 'tab-pp-client-discovery',
1159        'tab-pp-site-evaluation', 'tab-pp-go-nogo', 'tab-pp-competitive-analysis',
1160        'tab-pp-team-consultants', 'tab-pp-activity-log', 'tab-pp-documents'
1161    ];
1162    panelIds.forEach(id => {
1163        document.getElementById(id)?.classList.add('hidden');
1164    });
1165
1166    // Show active panel and init its module
1167    const panelMap = {
1168        'overview':              'tab-pp-overview',
1169        'lead-info':             'tab-pp-lead-info',
1170        'client-discovery':      'tab-pp-client-discovery',
1171        'site-evaluation':       'tab-pp-site-evaluation',
1172        'go-nogo':               'tab-pp-go-nogo',
1173        'competitive-analysis':  'tab-pp-competitive-analysis',
1174        'team-consultants':      'tab-pp-team-consultants',
1175        'activity-log':          'tab-pp-activity-log',
1176        'documents':             'tab-pp-documents',
1177    };
1178
1179    const panelId = panelMap[moduleId];
1180    if (panelId) {
1181        document.getElementById(panelId)?.classList.remove('hidden');
1182    }
1183
1184    // Init the active tab module
1185    if (currentPursuitId) {
1186        const signal = abortController?.signal;
1187        switch (moduleId) {
1188            case 'overview':
1189                initPpOverview(currentPursuitId, signal, pursuits);
1190                if (listCollapsed) expandList();
1191                break;
1192            case 'lead-info':
1193                initPpLeadInfo(currentPursuitId, signal);
1194                if (!listCollapsed) collapseList();
1195                break;
1196            case 'client-discovery':
1197                initPpClientDiscovery(currentPursuitId, signal);
1198                if (!listCollapsed) collapseList();
1199                break;
1200            case 'site-evaluation':
1201                initPpSiteEvaluation(currentPursuitId, signal);
1202                if (!listCollapsed) collapseList();
1203                break;
1204            case 'go-nogo':
1205                initPpGoNoGo(currentPursuitId, signal);
1206                if (!listCollapsed) collapseList();
1207                break;
1208            case 'competitive-analysis':
1209                initPpCompetitiveAnalysis(currentPursuitId, signal);
1210                if (!listCollapsed) collapseList();
1211                break;
1212            case 'team-consultants':
1213                initPpTeamConsultants(currentPursuitId, signal);
1214                if (!listCollapsed) collapseList();
1215                break;
1216            case 'activity-log':
1217                initPpActivityLog(currentPursuitId, signal);
1218                if (!listCollapsed) collapseList();
1219                break;
1220            case 'documents':
1221                documentsInitPromise = initProjectDocuments(currentPursuitId, signal, {
1222                    folderId: subTab ? parseInt(subTab, 10) || 0 : null,
1223                    onNavigate: ({ push = false } = {}) => updateUrl(push),
1224                });
1225                if (!listCollapsed) collapseList();
1226                break;
1227        }
1228    }
1229
1230    updateUrl(true);
1231    return documentsInitPromise;
1232}
1233
1234// ========================================
1235// COLLAPSE / EXPAND
1236// ========================================
1237
1238function initToggleHandler() {
1239    const toggleBtn = document.getElementById('btn-toggle-list');
1240    if (!toggleBtn) return;
1241
1242    toggleBtn.addEventListener('click', () => {
1243        if (listCollapsed) {
1244            expandList();
1245        } else {
1246            collapseList();
1247        }
1248    }, { signal: abortController.signal });
1249
1250    // Collapsed sidebar buttons
1251    const listColumn = document.getElementById('pursuit-list-column');
1252    if (listColumn) {
1253        listColumn.querySelector('.collapsed-expand-btn')?.addEventListener('click', () => expandList(), { signal: abortController.signal });
1254        listColumn.querySelector('.collapsed-add-btn')?.addEventListener('click', () => {
1255            document.getElementById('btn-create-pursuit')?.click();
1256        }, { signal: abortController.signal });
1257    }
1258}
1259
1260function collapseList() {
1261    if (isMobileOrTablet()) return;
1262
1263    const listColumn = document.getElementById('pursuit-list-column');
1264    const toggleBtn = document.getElementById('btn-toggle-list');
1265    if (!listColumn) return;
1266
1267    listCollapsed = true;
1268    listColumn.classList.add('collapsed');
1269
1270    if (toggleBtn) {
1271        toggleBtn.classList.remove('expanded');
1272        toggleBtn.title = 'Expand pursuit list';
1273    }
1274
1275    renderMiniPursuitList();
1276}
1277
1278function expandList() {
1279    const listColumn = document.getElementById('pursuit-list-column');
1280    const toggleBtn = document.getElementById('btn-toggle-list');
1281    if (!listColumn) return;
1282
1283    listCollapsed = false;
1284    listColumn.classList.remove('collapsed');
1285
1286    if (toggleBtn) {
1287        toggleBtn.classList.add('expanded');
1288        toggleBtn.title = 'Collapse pursuit list';
1289    }
1290}
1291
1292async function renderMiniPursuitList() {
1293    const miniContainer = document.getElementById('pursuits-list-mini');
1294    if (!miniContainer) return;
1295
1296    if (filteredPursuits.length === 0) {
1297        miniContainer.innerHTML = '';
1298        return;
1299    }
1300
1301    await renderTo(miniContainer, '#tpl-pp-row-mini', filteredPursuits);
1302
1303    // Highlight selected pursuit
1304    if (currentPursuitId) {
1305        miniContainer.querySelectorAll('.pursuit-row-mini').forEach(btn => {
1306            const isSelected = parseInt(btn.dataset.id) === currentPursuitId;
1307            btn.classList.toggle('bg-st-primary/10', isSelected);
1308            btn.classList.toggle('ring-2', isSelected);
1309            btn.classList.toggle('ring-st-primary', isSelected);
1310            btn.querySelectorAll('span').forEach(s => s.classList.toggle('!text-st-primary', isSelected));
1311        });
1312    }
1313
1314    miniContainer.onclick = (e) => {
1315        const row = e.target.closest('.pursuit-row-mini');
1316        if (row) {
1317            const id = parseInt(row.dataset.id);
1318            selectPursuit(id);
1319        }
1320    };
1321}
1322
1323// ========================================
1324// DETAIL LOGIC
1325// ========================================
1326
1327async function selectPursuit(id) {
1328    if (currentPursuitId === id) return;
1329
1330    // Update visual selection in full list
1331    const listContainer = document.getElementById('pursuits-list');
1332    listContainer?.querySelectorAll('.pursuit-row').forEach(r => {
1333        r.classList.remove('border-st-primary', 'bg-st-primary/5');
1334    });
1335
1336    const selectedRow = listContainer?.querySelector(`[data-id="${id}"]`);
1337    if (selectedRow) selectedRow.classList.add('border-st-primary', 'bg-st-primary/5');
1338
1339    currentPursuitId = id;
1340
1341    // Update mini list selection
1342    updateMiniListSelection();
1343
1344    // Sync mobile dropdown navigator
1345    DropdownNavigator.setSelected(DROPDOWN_NAV_ID, id);
1346
1347    // Push history entry so browser Back closes the detail pane
1348    updateUrl(true);
1349
1350    // Show detail pane with list data
1351    const listPursuit = pursuits.find(p => p.pursuitID === id);
1352    if (listPursuit) {
1353        await renderPursuitDetail(listPursuit);
1354    }
1355
1356    showDetailPane();
1357
1358    // Fetch full detail from API
1359    try {
1360        const response = await pursuitApi.getById(id);
1361        const fullPursuit = response?.pursuit || response;
1362
1363        if (currentPursuitId === id) {
1364            await renderPursuitDetail(fullPursuit);
1365        }
1366    } catch (error) {
1367        if (error.name === 'AbortError') return;
1368        console.warn('Failed to load pursuit detail:', error.message);
1369    }
1370}
1371
1372function updateMiniListSelection() {
1373    const miniContainer = document.getElementById('pursuits-list-mini');
1374    if (!miniContainer) return;
1375
1376    miniContainer.querySelectorAll('.pursuit-row-mini').forEach(btn => {
1377        const isSelected = parseInt(btn.dataset.id) === currentPursuitId;
1378        btn.classList.toggle('bg-st-primary/10', isSelected);
1379        btn.classList.toggle('ring-2', isSelected);
1380        btn.classList.toggle('ring-st-primary', isSelected);
1381    });
1382}
1383
1384async function renderPursuitDetail(pursuit) {
1385    const emptyState = document.getElementById('detail-empty');
1386    const content = document.getElementById('detail-content');
1387
1388    if (emptyState) emptyState.classList.add('hidden');
1389    if (content) content.classList.remove('hidden');
1390
1391    // Render Header
1392    const headerContainer = document.getElementById('detail-header');
1393    if (headerContainer) {
1394        await renderTo(headerContainer, '#tpl-pp-header', pursuit);
1395
1396        // Attach header actions
1397        document.getElementById('btn-edit-pursuit')?.addEventListener('click', () => handleEditPursuit(pursuit), { signal: abortController.signal });
1398        document.getElementById('btn-archive-pursuit')?.addEventListener('click', () => handleArchivePursuit(pursuit.pursuitID), { signal: abortController.signal });
1399        document.getElementById('btn-create-proposal-from-pursuit')?.addEventListener('click', () => handleCreateProposalFromPursuit(pursuit), { signal: abortController.signal });
1400        document.getElementById('btn-view-linked-proposal')?.addEventListener('click', () => {
1401            if (pursuit.linkedProposalID) page(`/proposals/${pursuit.linkedProposalID}`);
1402        }, { signal: abortController.signal });
1403        document.getElementById('btn-reactivate-pursuit')?.addEventListener('click', () => handleReactivatePursuit(pursuit), { signal: abortController.signal });
1404
1405        // Apply compact state if not on overview
1406        if (activeSubModule !== 'overview') {
1407            headerContainer.classList.add('header-compact');
1408        }
1409    }
1410
1411    // Render Health KPI Banner
1412    const kpiContainer = document.getElementById('pp-kpi-container');
1413    if (kpiContainer) {
1414        await renderTo(kpiContainer, '#tpl-pp-kpi-banner', pursuit);
1415    }
1416
1417    // Render sub-module tabs
1418    renderSubModuleTabs(PP_TABS, activeSubModule, 'pp');
1419
1420    // Init the active tab
1421    const pid = pursuit.pursuitID;
1422    const signal = abortController?.signal;
1423    switch (activeSubModule) {
1424        case 'overview':
1425            initPpOverview(pid, signal, pursuits);
1426            break;
1427        case 'lead-info':
1428            initPpLeadInfo(pid, signal);
1429            break;
1430        case 'client-discovery':
1431            initPpClientDiscovery(pid, signal);
1432            break;
1433        case 'site-evaluation':
1434            initPpSiteEvaluation(pid, signal);
1435            break;
1436        case 'go-nogo':
1437            initPpGoNoGo(pid, signal);
1438            break;
1439        case 'competitive-analysis':
1440            initPpCompetitiveAnalysis(pid, signal);
1441            break;
1442        case 'team-consultants':
1443            initPpTeamConsultants(pid, signal);
1444            break;
1445        case 'activity-log':
1446            initPpActivityLog(pid, signal);
1447            break;
1448        case 'documents':
1449            await initProjectDocuments(pid, signal, { onNavigate: ({ push = false } = {}) => updateUrl(push) });
1450            break;
1451    }
1452
1453    // Update mini list selection if collapsed
1454    if (listCollapsed) {
1455        updateMiniListSelection();
1456    }
1457}
1458
1459// ========================================
1460// MOBILE SUPPORT
1461// ========================================
1462
1463function showDetailPane() {
1464    const emptyState = document.getElementById('detail-empty');
1465    const detailContent = document.getElementById('detail-content');
1466    const listColumn = document.getElementById('pursuit-list-column');
1467    const detailColumn = document.getElementById('pursuit-detail-column');
1468    const mobileBackHeader = document.getElementById('pursuit-mobile-back-header');
1469
1470    if (emptyState) emptyState.classList.add('hidden');
1471    if (detailContent) detailContent.classList.remove('hidden');
1472
1473    if (isMobileOrTablet()) {
1474        if (listColumn) listColumn.classList.add('hidden');
1475        if (detailColumn) {
1476            detailColumn.classList.remove('hidden', 'lg:flex');
1477            detailColumn.classList.add('flex', 'w-full');
1478        }
1479        if (mobileBackHeader) mobileBackHeader.classList.remove('hidden');
1480    }
1481}
1482
1483function showMobileList() {
1484    const listColumn = document.getElementById('pursuit-list-column');
1485    const detailColumn = document.getElementById('pursuit-detail-column');
1486    const mobileBackHeader = document.getElementById('pursuit-mobile-back-header');
1487    const emptyState = document.getElementById('detail-empty');
1488    const detailContent = document.getElementById('detail-content');
1489
1490    if (listColumn) listColumn.classList.remove('hidden');
1491
1492    if (detailColumn) {
1493        detailColumn.classList.remove('flex', 'w-full');
1494        detailColumn.classList.add('hidden', 'lg:flex');
1495    }
1496
1497    if (mobileBackHeader) mobileBackHeader.classList.add('hidden');
1498    if (emptyState) emptyState.classList.remove('hidden');
1499    if (detailContent) detailContent.classList.add('hidden');
1500
1501    document.querySelectorAll('#pursuits-list .pursuit-row').forEach(card => {
1502        card.classList.remove('border-st-primary', 'bg-st-primary/5');
1503    });
1504
1505    currentPursuitId = null;
1506    activeSubModule = 'overview';
1507    updateUrl(true);
1508}
1509
1510function initMobileBackHandler() {
1511    const backBtn = document.getElementById('btn-pursuit-mobile-back');
1512    if (backBtn) {
1513        backBtn.addEventListener('click', showMobileList, { signal: abortController.signal });
1514    }
1515}
1516
1517// ========================================
1518// DROPDOWN NAVIGATOR (Mobile < 768px)
1519// ========================================
1520
1521const DROPDOWN_NAV_ID = 'pursuit-dropdown-nav';
1522
1523function initDropdownNavigator() {
1524    const container = document.getElementById(DROPDOWN_NAV_ID);
1525    if (!container) return;
1526
1527    if (isMobile()) {
1528        container.classList.remove('hidden');
1529    }
1530
1531    DropdownNavigator.init({
1532        containerId: DROPDOWN_NAV_ID,
1533        items: buildDropdownItems(),
1534        selectedId: currentPursuitId,
1535        onSelect: (id) => selectPursuit(id),
1536        onNew: () => page('/project-pursuit/new'),
1537        newLabel: 'New Pursuit',
1538        emptyLabel: 'No pursuits found',
1539        moduleTitle: 'Project Pursuit',
1540        moduleIcon: 'crosshair'
1541    });
1542}
1543
1544function syncDropdownNavigator() {
1545    DropdownNavigator.setItems(DROPDOWN_NAV_ID, buildDropdownItems());
1546    DropdownNavigator.setSelected(DROPDOWN_NAV_ID, currentPursuitId);
1547}
1548
1549function buildDropdownItems() {
1550    return filteredPursuits.map(p => ({
1551        id: p.pursuitID,
1552        badge: p.stage || null,
1553        name: p.opportunityName || 'Untitled',
1554        subtitle: p.clientName || p.locationCity || null
1555    }));
1556}
1557
1558// ========================================
1559// CRUD ACTIONS
1560// ========================================
1561
1562function handleCreatePursuit() {
1563    page('/project-pursuit/new');
1564}
1565
1566function handleEditPursuit(pursuit) {
1567    // Edit not yet implemented in Phase 0 — just toast
1568    window.showToast?.('Edit coming soon', 'info', { description: 'Pursuit editing will be available in Phase 1.' });
1569}
1570
1571async function handleArchivePursuit(id) {
1572    await confirm({
1573        title: 'Archive this pursuit?',
1574        message: 'The pursuit will be moved to the archive.',
1575        icon: 'archive',
1576        confirmText: 'Archive',
1577        confirmClass: 'btn-st-error',
1578        async onConfirm() {
1579            try {
1580                await pursuitApi.archive(id);
1581                window.showToast?.('Pursuit archived', 'success');
1582                await loadPursuits();
1583                hideDetailPane();
1584            } catch (error) {
1585                if (error.name === 'AbortError') return;
1586                window.showToast?.('Failed to archive', 'error', { description: error.message });
1587            }
1588        }
1589    });
1590}
1591
1592async function handleCreateProposalFromPursuit(pursuit) {
1593    const confirmed = await confirm({
1594        title: 'Create Proposal from Pursuit',
1595        message: `Create a new proposal pre-filled with data from "${pursuit.opportunityName}"? You'll be taken to the proposal form to review and complete required fields.`,
1596        icon: 'file-plus',
1597        confirmText: 'Continue to Proposal Form',
1598        confirmClass: 'btn-st-primary'
1599    });
1600    if (!confirmed) return;
1601
1602    sessionStorage.setItem('sb_pursuit_handoff', JSON.stringify({
1603        pursuitId: pursuit.pursuitID,
1604        opportunityName: pursuit.opportunityName,
1605        projectType: pursuit.projectType,
1606        estimatedBudget: pursuit.estimatedBudget,
1607        leadSource: pursuit.leadSource,
1608        anticipatedConstructionStart: pursuit.anticipatedConstructionStart,
1609        anticipatedCompletion: pursuit.anticipatedCompletion,
1610        deadlineToDecide: pursuit.deadlineToDecide,
1611        clientName: pursuit.clientName,
1612        clientContactPerson: pursuit.clientContactPerson,
1613        contactEmail: pursuit.contactEmail,
1614        rfpNumber: pursuit.rfpNumber
1615    }));
1616
1617    page('/proposals/new');
1618}
1619
1620async function handleReactivatePursuit(pursuit) {
1621    const confirmed = await confirm({
1622        title: 'Re-activate this pursuit?',
1623        message: 'Create a new pursuit from this archived No-Go? Key data will be copied and the new pursuit will start at New Lead stage.',
1624        icon: 'refresh-cw',
1625        confirmText: 'Re-activate',
1626        confirmClass: 'btn-st-primary'
1627    });
1628    if (!confirmed) return;
1629
1630    try {
1631        const result = await pursuitApi.create({
1632            pursuitName: pursuit.opportunityName,
1633            leadSource: pursuit.leadSource,
1634            clientName: pursuit.clientName,
1635            contactName: pursuit.clientContactPerson || '',
1636            contactEmail: pursuit.contactEmail || '',
1637            contactPhone: pursuit.contactPhone || '',
1638            dateIdentified: todayISO(),
1639            projectType: pursuit.projectType,
1640            estimatedBudget: pursuit.estimatedBudget,
1641            city: pursuit.locationCity || '',
1642            province: pursuit.locationProvince || '',
1643            assignedPrincipalId: pursuit.assignedPrincipalID,
1644            notes: `Re-activated from archived pursuit (ID: ${pursuit.pursuitID}). Originally evaluated as No-Go on ${pursuit.decisionDate ? new Date(pursuit.decisionDate).toLocaleDateString() : 'unknown date'}, score: ${pursuit.goNoGoScore ?? 'N/A'}.`,
1645            referredBy: pursuit.referredBy || ''
1646        });
1647
1648        window.showToast?.('Pursuit re-activated', 'success', {
1649            description: 'New pursuit created from archived data.'
1650        });
1651
1652        const newId = result?.pursuitID || result?.pursuitId;
1653        await loadPursuits();
1654        if (newId) {
1655            await selectPursuit(newId);
1656        }
1657    } catch (error) {
1658        if (error.name === 'AbortError') return;
1659        window.showToast?.('Failed to re-activate', 'error', { description: error.message });
1660    }
1661}
1662
1663function hideDetailPane() {
1664    const emptyState = document.getElementById('detail-empty');
1665    const detailContent = document.getElementById('detail-content');
1666
1667    if (emptyState) emptyState.classList.remove('hidden');
1668    if (detailContent) detailContent.classList.add('hidden');
1669
1670    currentPursuitId = null;
1671}
1672
1673// ========================================
1674// Exports
1675// ========================================
1676export { selectPursuit, hideDetailPane, showMobileList };

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.