tool/Automation/functions/Export-HtmlReport.ps1
|
function Export-HtmlReport { <# .SYNOPSIS Generate a self-contained interactive HTML report from scored results. .DESCRIPTION Produces a single HTML file with embedded CSS/JS (no external dependencies). Features: score bar chart, band distribution, sortable table, detail expansion, band/urgency filter, and search. .PARAMETER Results Array of flat result hashtables (same shape as opportunity-report.json .results). .PARAMETER Metadata Hashtable with profile, exported_at, source info. .PARAMETER OutputPath Directory to write opportunity-report.html into. .OUTPUTS String — path to the generated HTML file. #> [CmdletBinding()] param( [Parameter(Mandatory)] [array]$Results, [Parameter(Mandatory)] [hashtable]$Metadata, [Parameter(Mandatory)] [string]$OutputPath ) $htmlPath = Join-Path $OutputPath 'opportunity-report.html' # Convert results to JSON for embedding $jsonData = $Results | ConvertTo-Json -Depth 5 -Compress # Band stats $bandCounts = @(0, 0, 0, 0, 0) foreach ($r in $Results) { $band = [int]$r.Band if ($band -ge 1 -and $band -le 5) { $bandCounts[$band - 1]++ } } $totalContacts = $Results.Count $avgScore = if ($totalContacts -gt 0) { [math]::Round(($Results | ForEach-Object { $_.'Final Score' } | Measure-Object -Average).Average, 1) } else { 0 } $maxScore = if ($totalContacts -gt 0) { ($Results | ForEach-Object { $_.'Final Score' } | Measure-Object -Maximum).Maximum } else { 0 } $html = @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>LeadForge Opportunity Report</title> <style> :root { --band1: #2ecc71; --band2: #3498db; --band3: #f39c12; --band4: #e74c3c; --band5: #95a5a6; --bg: #f8f9fa; --card: #ffffff; --text: #2c3e50; --text-muted: #6c757d; --border: #dee2e6; --hover: #e9ecef; } * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; padding: 1.5rem; } .container { max-width: 1400px; margin: 0 auto; } h1 { font-size: 1.8rem; margin-bottom: 0.25rem; } .subtitle { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 1.5rem; } /* Summary Cards */ .summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } .card { background: var(--card); border-radius: 8px; padding: 1.25rem; box-shadow: 0 1px 3px rgba(0,0,0,0.08); text-align: center; } .card .value { font-size: 2rem; font-weight: 700; } .card .label { font-size: 0.8rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } /* Band pills */ .bands { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; flex-wrap: wrap; } .band-pill { padding: 0.5rem 1rem; border-radius: 20px; font-size: 0.85rem; font-weight: 600; color: #fff; cursor: pointer; opacity: 0.85; transition: opacity 0.2s, transform 0.2s; } .band-pill:hover, .band-pill.active { opacity: 1; transform: scale(1.05); } .band-pill.b1 { background: var(--band1); } .band-pill.b2 { background: var(--band2); } .band-pill.b3 { background: var(--band3); } .band-pill.b4 { background: var(--band4); } .band-pill.b5 { background: var(--band5); } /* Charts */ .charts-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem; } .chart-section { background: var(--card); border-radius: 8px; padding: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } .chart-section h2 { font-size: 1rem; margin-bottom: 1rem; } /* Histogram (left) */ .histogram { display: flex; align-items: flex-end; gap: 2px; height: 140px; } .hist-bar-wrap { flex: 1; display: flex; flex-direction: column; align-items: center; height: 100%; justify-content: flex-end; } .hist-bar { width: 100%; border-radius: 3px 3px 0 0; min-height: 0; transition: height 0.3s; position: relative; } .hist-bar:hover { opacity: 0.85; } .hist-count { font-size: 0.7rem; font-weight: 600; margin-bottom: 2px; color: var(--text); } .hist-label { font-size: 0.7rem; color: var(--text-muted); margin-top: 4px; text-align: center; } /* Top-N horizontal bars (right) */ .hbar-chart { display: flex; flex-direction: column; gap: 6px; } .hbar-row { display: flex; align-items: center; gap: 8px; } .hbar-name { width: 130px; font-size: 0.8rem; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0; } .hbar-track { flex: 1; height: 22px; background: #ecf0f1; border-radius: 4px; overflow: hidden; position: relative; } .hbar-fill { height: 100%; border-radius: 4px; transition: width 0.4s; display: flex; align-items: center; padding-left: 6px; } .hbar-score { font-size: 0.75rem; font-weight: 600; color: #fff; text-shadow: 0 1px 1px rgba(0,0,0,0.3); } .hbar-org { font-size: 0.7rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: 120px; flex-shrink: 0; } .hbar-more { font-size: 0.8rem; color: var(--text-muted); margin-top: 4px; text-align: center; } @media (max-width: 768px) { .charts-row { grid-template-columns: 1fr; } .hbar-name { width: 90px; } .hbar-org { display: none; } } /* Controls */ .controls { display: flex; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap; align-items: center; } .search-input { padding: 0.5rem 1rem; border: 1px solid var(--border); border-radius: 6px; font-size: 0.9rem; width: 250px; } .search-input:focus { outline: none; border-color: var(--band2); box-shadow: 0 0 0 2px rgba(52,152,219,0.2); } /* Table */ .table-wrap { background: var(--card); border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } table { width: 100%; border-collapse: collapse; font-size: 0.85rem; } thead { background: #2c3e50; color: #fff; position: sticky; top: 0; } th { padding: 0.75rem 0.5rem; text-align: left; cursor: pointer; user-select: none; white-space: nowrap; } th:hover { background: #34495e; } th .sort-arrow { margin-left: 4px; opacity: 0.5; } th.sorted .sort-arrow { opacity: 1; } td { padding: 0.6rem 0.5rem; border-bottom: 1px solid var(--border); vertical-align: top; } tr:hover { background: var(--hover); } tr.band-1 td:first-child { border-left: 3px solid var(--band1); } tr.band-2 td:first-child { border-left: 3px solid var(--band2); } tr.band-3 td:first-child { border-left: 3px solid var(--band3); } tr.band-4 td:first-child { border-left: 3px solid var(--band4); } tr.band-5 td:first-child { border-left: 3px solid var(--band5); } /* Detail row */ .detail-row td { padding: 0; border: none; background: #f0f4f8; } .detail-content { padding: 1rem 1.5rem; display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; font-size: 0.85rem; } .detail-content h4 { grid-column: 1 / -1; margin-bottom: 0.25rem; font-size: 0.9rem; } .detail-content .field { display: flex; flex-direction: column; } .detail-content .field-label { font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; } .detail-content .field-value { font-weight: 500; } .score-breakdown { display: flex; gap: 0.5rem; flex-wrap: wrap; grid-column: 1 / -1; } .score-chip { padding: 0.25rem 0.6rem; border-radius: 4px; font-size: 0.75rem; background: #e8edf2; } .hidden { display: none; } @media (max-width: 768px) { .summary { grid-template-columns: repeat(2, 1fr); } .controls { flex-direction: column; align-items: stretch; } .search-input { width: 100%; } .detail-content { grid-template-columns: 1fr; } } </style> </head> <body> <div class="container"> <h1>LeadForge Opportunity Report</h1> <div class="subtitle">Generated $(Get-Date -Format 'yyyy-MM-dd HH:mm') • Profile: $($Metadata.profile ?? 'Default') • $totalContacts contacts scored</div> <!-- Summary Cards --> <div class="summary"> <div class="card"><div class="value">$totalContacts</div><div class="label">Contacts</div></div> <div class="card"><div class="value">$maxScore</div><div class="label">Top Score</div></div> <div class="card"><div class="value">$avgScore</div><div class="label">Avg Score</div></div> <div class="card"><div class="value">$($bandCounts[0] + $bandCounts[1] + $bandCounts[2])</div><div class="label">Actionable (B1-3)</div></div> </div> <!-- Band Filter --> <div class="bands"> <span class="band-pill b1 active" data-band="1" onclick="toggleBand(1)">Band 1: Immediate ($($bandCounts[0]))</span> <span class="band-pill b2 active" data-band="2" onclick="toggleBand(2)">Band 2: High ($($bandCounts[1]))</span> <span class="band-pill b3 active" data-band="3" onclick="toggleBand(3)">Band 3: Medium ($($bandCounts[2]))</span> <span class="band-pill b4 active" data-band="4" onclick="toggleBand(4)">Band 4: Low ($($bandCounts[3]))</span> <span class="band-pill b5 active" data-band="5" onclick="toggleBand(5)">Band 5: Archive ($($bandCounts[4]))</span> </div> <!-- Charts --> <div class="charts-row"> <div class="chart-section"> <h2>Score Distribution</h2> <div class="histogram" id="histogram"></div> </div> <div class="chart-section"> <h2>Top Opportunities</h2> <div class="hbar-chart" id="topChart"></div> </div> </div> <!-- Controls --> <div class="controls"> <input type="text" class="search-input" id="searchInput" placeholder="Search name, company, email..." oninput="filterTable()"> </div> <!-- Table --> <div class="table-wrap"> <table id="reportTable"> <thead> <tr> <th onclick="sortTable(0)">Score <span class="sort-arrow">▲</span></th> <th onclick="sortTable(1)">Band <span class="sort-arrow">▲</span></th> <th onclick="sortTable(2)">Name <span class="sort-arrow">▲</span></th> <th onclick="sortTable(3)">Organisation <span class="sort-arrow">▲</span></th> <th onclick="sortTable(4)">Category <span class="sort-arrow">▲</span></th> <th onclick="sortTable(5)">Confidence <span class="sort-arrow">▲</span></th> <th onclick="sortTable(6)">Action <span class="sort-arrow">▲</span></th> </tr> </thead> <tbody id="tableBody"></tbody> </table> </div> </div> <script> const DATA = $jsonData; const bandColors = {1:'#2ecc71',2:'#3498db',3:'#f39c12',4:'#e74c3c',5:'#95a5a6'}; const bandNames = {1:'Immediate',2:'High',3:'Medium',4:'Low',5:'Archive'}; let activeBands = new Set([1,2,3,4,5]); let sortCol = -1, sortAsc = false; let expandedRow = null; function toggleBand(b) { const pill = document.querySelector('.band-pill[data-band="'+b+'"]'); if (activeBands.has(b)) { activeBands.delete(b); pill.classList.remove('active'); pill.style.opacity='0.4'; } else { activeBands.add(b); pill.classList.add('active'); pill.style.opacity=''; } renderTable(); } function getFiltered() { const q = (document.getElementById('searchInput').value||'').toLowerCase(); return DATA.filter(r => { if (!activeBands.has(r.Band)) return false; if (!q) return true; return [r['Contact Name'],r.Organisation,r['Contact Email'],r.Category,r['Recommended Action']] .some(v => v && v.toLowerCase().includes(q)); }); } function renderChart() { // --- Left: Score histogram (10 buckets) --- const hist = document.getElementById('histogram'); const bucketSize = 10; const buckets = Array.from({length:10}, ()=>({count:0, bands:{}})); DATA.forEach(r => { const idx = Math.min(9, Math.floor(r['Final Score'] / bucketSize)); buckets[idx].count++; buckets[idx].bands[r.Band] = (buckets[idx].bands[r.Band]||0) + 1; }); const maxCount = Math.max(...buckets.map(b=>b.count), 1); hist.innerHTML = buckets.map((b,i) => { const h = b.count > 0 ? Math.max(8, (b.count/maxCount)*100) : 0; // Determine dominant band colour for this bucket let dominantBand = 5; let maxBandCount = 0; for (const [band, cnt] of Object.entries(b.bands)) { if (cnt > maxBandCount) { maxBandCount = cnt; dominantBand = parseInt(band); } } const color = bandColors[dominantBand]||'#ccc'; const label = (i*bucketSize)+'-'+(i*bucketSize+bucketSize-1); return '<div class="hist-bar-wrap">' +(b.count>0?'<div class="hist-count">'+b.count+'</div>':'') +'<div class="hist-bar" style="height:'+h+'%;background:'+color+'" title="'+label+': '+b.count+' contacts"></div>' +'<div class="hist-label">'+label+'</div></div>'; }).join(''); // --- Right: Top 10 horizontal bars --- const topChart = document.getElementById('topChart'); const top10 = [...DATA].sort((a,b) => b['Final Score'] - a['Final Score']).slice(0, 10); const topMax = Math.max(...top10.map(r=>r['Final Score']), 1); const remaining = DATA.length - top10.length; topChart.innerHTML = top10.map(r => { const pct = Math.max(5, (r['Final Score']/topMax)*100); const color = bandColors[r.Band]||'#ccc'; return '<div class="hbar-row">' +'<div class="hbar-name" title="'+esc(r['Contact Name'])+'">'+esc(r['Contact Name'])+'</div>' +'<div class="hbar-track"><div class="hbar-fill" style="width:'+pct+'%;background:'+color+'"><span class="hbar-score">'+r['Final Score']+'</span></div></div>' +'<div class="hbar-org" title="'+esc(r.Organisation)+'">'+esc(r.Organisation)+'</div>' +'</div>'; }).join('') + (remaining > 0 ? '<div class="hbar-more">+ '+remaining+' more in table below</div>' : ''); } function renderTable() { const tbody = document.getElementById('tableBody'); let rows = getFiltered(); rows.sort((a,b) => { const keys = ['Final Score','Band','Contact Name','Organisation','Category','Research Confidence','Recommended Action']; let av = a[keys[sortCol]], bv = b[keys[sortCol]]; if (typeof av === 'number') return sortAsc ? av-bv : bv-av; av = (av||'').toString().toLowerCase(); bv = (bv||'').toString().toLowerCase(); return sortAsc ? av.localeCompare(bv) : bv.localeCompare(av); }); tbody.innerHTML = rows.map((r,i) => { const action = (r['Recommended Action']||'').substring(0,80) + ((r['Recommended Action']||'').length>80?'...':''); return '<tr class="data-row band-'+r.Band+'" onclick="toggleDetail(this,'+i+')" data-idx="'+i+'">' +'<td><strong>'+r['Final Score']+'</strong></td>' +'<td><span style="color:'+bandColors[r.Band]+'">●</span> '+bandNames[r.Band]+'</td>' +'<td>'+esc(r['Contact Name'])+'</td>' +'<td>'+esc(r.Organisation)+'</td>' +'<td>'+esc(r.Category)+'</td>' +'<td>'+r['Research Confidence']+'/5</td>' +'<td>'+esc(action)+'</td></tr>'; }).join(''); expandedRow = null; } function toggleDetail(tr, idx) { const rows = getFiltered(); rows.sort((a,b) => { const keys = ['Final Score','Band','Contact Name','Organisation','Category','Research Confidence','Recommended Action']; let av = a[keys[sortCol]], bv = b[keys[sortCol]]; if (typeof av === 'number') return sortAsc ? av-bv : bv-av; av=(av||'').toString().toLowerCase(); bv=(bv||'').toString().toLowerCase(); return sortAsc ? av.localeCompare(bv) : bv.localeCompare(av); }); const r = rows[idx]; const existing = tr.nextElementSibling; if (existing && existing.classList.contains('detail-row')) { existing.remove(); expandedRow=null; return; } // Remove any other expanded document.querySelectorAll('.detail-row').forEach(el=>el.remove()); const detail = document.createElement('tr'); detail.className = 'detail-row'; detail.innerHTML = '<td colspan="7"><div class="detail-content">' +'<h4>'+esc(r['Contact Name'])+' — '+esc(r.Organisation)+'</h4>' +'<div class="field"><span class="field-label">Email</span><span class="field-value">'+esc(r['Contact Email'])+'</span></div>' +'<div class="field"><span class="field-label">Source File</span><span class="field-value">'+esc(r['Source File'])+'</span></div>' +'<div class="score-breakdown">' +'<span class="score-chip">Fit: '+r['Strategic Fit']+'/5</span>' +'<span class="score-chip">Seniority: '+r.Seniority+'/5</span>' +'<span class="score-chip">Warmth: '+r['Engagement Warmth']+'/5</span>' +'<span class="score-chip">Market: '+r['Market Activity']+'/5</span>' +'<span class="score-chip">Stage: '+r['Conversation Stage']+'/5</span>' +'<span class="score-chip">Recency: '+r.Recency+'/5</span>' +'<span class="score-chip">Confidence: '+r['Research Confidence']+'/5</span>' +'<span class="score-chip" style="background:#d4efdf;font-weight:600">Composite: '+r['Composite Score']+'</span>' +'<span class="score-chip" style="background:#fadbd8;font-weight:600">Penalty: -'+r['Recency Penalty']+'</span>' +'</div>' +'<div class="field" style="grid-column:1/-1"><span class="field-label">Recommended Action</span><span class="field-value">'+esc(r['Recommended Action'])+'</span></div>' +'<div class="field" style="grid-column:1/-1"><span class="field-label">Rationale</span><span class="field-value">'+esc(r['Action Rationale'])+'</span></div>' +'</div></td>'; tr.after(detail); expandedRow = idx; } function sortTable(col) { if (sortCol === col) sortAsc = !sortAsc; else { sortCol = col; sortAsc = col >= 2; } document.querySelectorAll('th').forEach(th=>th.classList.remove('sorted')); document.querySelectorAll('th')[col].classList.add('sorted'); renderTable(); } function filterTable() { renderTable(); } function esc(s) { if (!s) return ''; const d=document.createElement('div'); d.textContent=s; return d.innerHTML; } // Init renderChart(); sortTable(0); </script> </body> </html> "@ $html | Set-Content -Path $htmlPath -Encoding UTF8 return $htmlPath } |