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.