1/** 2 * portfolio.js â CHOP portfolio dashboard. 3 * 4 * Loads a single portfolio snapshot, renders the project table, and applies 5 * client-side health/phase/search filters with sorting. Includes the CHOP 6 * Task/Gate Log exports (Excel + PDF). 7 */ 8 9// ======================================== 10// IMPORTS 11// ======================================== 12 13import { portfolioApi } from '../api/portfolio.js'; 14import { render, loadTemplates } from '../utils/templates.js'; 15import { runWithBusyButton } from '../utils/dom.js'; 16import { attachDropdownMenu } from '../utils/dropdown-menu.js'; 17import { downloadBlob } from '../utils/download.js'; 18import { debounce } from '../utils/debounce.js'; 19 20// ======================================== 21// MODULE STATE 22// ======================================== 23 24let abortController = null; 25let portfolioData = null; 26let portfolioTemplatesLoaded = false; 27 28// Current filter/sort state 29let activeHealth = 'all'; 30let activePhase = 'all'; 31let activeSort = 'health'; 32let searchTerm = ''; 33 34// ======================================== 35// PUBLIC API 36// ======================================== 37 38/** @param {AbortSignal|null} signal - the route transition's signal; a later route aborts it (CA-556). */ 39export async function initPortfolioPage(signal = null) { 40 abortController = new AbortController(); 41 const pageSignal = abortController.signal; 42 if (!portfolioTemplatesLoaded) { 43 await loadTemplates('components/project-sub-templates'); 44 portfolioTemplatesLoaded = true; 45 } 46 if (signal?.aborted || pageSignal.aborted) return; 47 48 // Show skeleton loading state 49 const appContent = document.getElementById('app-content'); 50 if (appContent) { 51 appContent.innerHTML = ` 52 <div class="space-y-6 p-6"> 53 ${$.templates('#tpl-skeleton-stats').render({})} 54 ${$.templates('#tpl-skeleton-table').render({})} 55 </div>`; 56 } 57 58 try { 59 const data = await portfolioApi.getPortfolio({ signal: pageSignal }); 60 if (pageSignal.aborted) return; 61 portfolioData = data; 62 const html = await render('portfolio-main', portfolioData, '#app-content', undefined, { signal }); 63 if (html === null || pageSignal.aborted) return; 64 attachEventHandlers(); 65 applyFiltersAndSort(); 66 } catch (err) { 67 if (err.name === 'AbortError') return; 68 console.error('[Portfolio] Failed to load portfolio data:', err); 69 } 70} 71 72export function destroyPortfolioPage() { 73 if (abortController) { 74 abortController.abort(); 75 abortController = null; 76 } 77 portfolioData = null; 78 activeHealth = 'all'; 79 activePhase = 'all'; 80 activeSort = 'health'; 81 searchTerm = ''; 82} 83 84// ======================================== 85// EVENT WIRING 86// ======================================== 87 88function attachEventHandlers() { 89 const signal = abortController.signal; 90 91 // Health filter pills (delegated) 92 document.getElementById('healthFilter')?.addEventListener('click', (e) => { 93 const pill = e.target.closest('.health-pill'); 94 if (!pill) return; 95 activeHealth = pill.dataset.health; 96 document.querySelectorAll('.health-pill').forEach(p => p.classList.remove('active')); 97 pill.classList.add('active'); 98 applyFiltersAndSort(); 99 }, { signal }); 100 101 // Phase filter dropdown 102 document.getElementById('phaseFilter')?.addEventListener('change', (e) => { 103 activePhase = e.target.value; 104 applyFiltersAndSort(); 105 }, { signal }); 106 107 // Sort dropdown 108 document.getElementById('sortOrder')?.addEventListener('change', (e) => { 109 activeSort = e.target.value; 110 applyFiltersAndSort(); 111 }, { signal }); 112 113 // Search input (debounced) 114 const debouncedSearch = debounce((value) => { 115 searchTerm = value.trim().toLowerCase(); 116 applyFiltersAndSort(); 117 }, 200); 118 document.getElementById('portfolioSearch')?.addEventListener('input', (e) => { 119 debouncedSearch(e.target.value); 120 }, { signal }); 121 122 // ââ CHOP Export dropdown ââ 123 const exportDropdown = attachDropdownMenu('btn-chop-export', 'chop-export-menu', { signal }); 124 document.addEventListener('click', async (e) => { 125 const btn = e.target.closest('.chop-export-btn'); 126 if (!btn) return; 127 exportDropdown.close(); 128 const report = btn.dataset.report; 129 const format = btn.dataset.format; 130 await runWithBusyButton(btn, 'Generating...', async () => { 131 let result; 132 if (report === 'task') { 133 result = format === 'pdf' ? await portfolioApi.downloadTaskLogPdf() : await portfolioApi.downloadTaskLogExcel(); 134 } else { 135 result = format === 'pdf' ? await portfolioApi.downloadGateLogPdf() : await portfolioApi.downloadGateLogExcel(); 136 } 137 downloadBlob(result.blob, result.fileName, { successMessage: `${format.toUpperCase()} downloaded` }); 138 }, { errorTitle: 'Export failed', errorLogPrefix: '[Portfolio] Export failed:', resetOnSuccess: true }); 139 }, { signal }); 140 141 // Project row clicks â navigate to drill-down 142 document.getElementById('projectRows')?.addEventListener('click', (e) => {
143 const row = e.target.closest('.project-row'); 144 if (!row) return; 145 const projectId = row.dataset.projectId; 146 if (projectId) { 147 page('/portfolio/' + projectId); 148 } 149 }, { signal }); 150} 151 152// ======================================== 153// FILTER & SORT 154// ======================================== 155 156function applyFiltersAndSort() { 157 const container = document.getElementById('projectRows'); 158 if (!container) return; 159 160 const rows = Array.from(container.querySelectorAll('.project-row')); 161 162 // Filter 163 let visibleCount = 0; 164 rows.forEach(row => { 165 const matchesHealth = activeHealth === 'all' || row.dataset.health === activeHealth; 166 const matchesPhase = activePhase === 'all' || row.dataset.phase === activePhase; 167 const matchesSearch = !searchTerm || row.dataset.name.toLowerCase().includes(searchTerm); 168 const visible = matchesHealth && matchesPhase && matchesSearch; 169 170 row.classList.toggle('hidden', !visible); 171 if (visible) visibleCount++; 172 }); 173 174 // Sort visible rows 175 // Default: gate-blocked first, then red, amber, green. Within tier: by deadline. 176 const healthOrder = { red: 0, amber: 1, green: 2 }; 177 const sortedRows = rows.filter(r => !r.classList.contains('hidden')); 178 179 sortedRows.sort((a, b) => { 180 switch (activeSort) { 181 case 'name': 182 return a.dataset.name.localeCompare(b.dataset.name); 183 case 'velocity': 184 return parseFloat(b.dataset.velocity) - parseFloat(a.dataset.velocity); 185 case 'compliance': 186 return parseInt(b.dataset.compliance) - parseInt(a.dataset.compliance); 187 case 'deadline': 188 return parseInt(a.dataset.deadlineSort) - parseInt(b.dataset.deadlineSort); 189 case 'health': 190 default: { 191 // Gate-blocked projects float to top 192 const aBlocked = a.dataset.gateBlocked === 'true' ? -1 : 0; 193 const bBlocked = b.dataset.gateBlocked === 'true' ? -1 : 0; 194 if (aBlocked !== bBlocked) return aBlocked - bBlocked; 195 // Then by health severity 196 const aHealth = healthOrder[a.dataset.health] ?? 99; 197 const bHealth = healthOrder[b.dataset.health] ?? 99; 198 if (aHealth !== bHealth) return aHealth - bHealth; 199 // Then by deadline within same health tier 200 return parseInt(a.dataset.deadlineSort) - parseInt(b.dataset.deadlineSort); 201 } 202 } 203 }); 204 205 // Re-append in sorted order 206 const hiddenRows = rows.filter(r => r.classList.contains('hidden')); 207 sortedRows.forEach(r => container.appendChild(r)); 208 hiddenRows.forEach(r => container.appendChild(r)); 209 210 // Update visible count badge 211 const countEl = document.getElementById('visibleCount'); 212 if (countEl) { 213 const total = rows.length; 214 countEl.textContent = visibleCount < total ? `(${visibleCount} of ${total})` : ''; 215 } 216 217 // Show/hide empty state 218 const noResults = document.getElementById('noResults'); 219 if (noResults) { 220 noResults.classList.toggle('hidden', visibleCount > 0); 221 } 222}
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.