Private/UI/New-HtmlReport.ps1

# Copyright (c) 2026 Sandy Zeng. All rights reserved.
# Source-available. All rights reserved. See LICENSE file.

<#
    New-HtmlReport.ps1 — Generates a self-contained HTML diff report from comparison result rows.
 
    Author: Sandy Zeng
    Project: IntuneDiff
 
    Version History:
    1.0.0 Initial release.
#>


function New-HtmlReport {
    <#
    .SYNOPSIS
        Generates a self-contained HTML diff report from comparison rows.
 
    .PARAMETER Title
        The report title (shown at the top).
 
    .PARAMETER Rows
        Array of PSCustomObjects to display in the table.
 
    .PARAMETER Columns
        Ordered array of property names to render as columns.
 
    .PARAMETER StatusColumn
        Name of the column used for row color-coding (Result / Status).
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [string]$Title,
        [Parameter(Mandatory)] [object[]]$Rows,
        [Parameter(Mandatory)] [string[]]$Columns,
        [string]$StatusColumn = 'Result',
        [object]$Summary
    )

    $css = @'
<style>
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; background-color: #f5f5f5; }
  .container { max-width: 1400px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
  .report-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 20px; border-bottom: 2px solid #e5e7eb; }
  .branding { display: flex; align-items: center; gap: 15px; }
  .branding h1 { margin: 0; color: #3b82f6; }
  .branding p { margin: 0; color: #6b7280; font-size: 14px; }
  .header-timestamp { text-align: right; color: #6b7280; font-size: 14px; }
  h2 { color: #1f2937; margin: 20px 0 10px 0; }
  .info-box { background: #eff6ff; padding: 15px; border-radius: 6px; margin: 20px 0; }
  .info-box p { margin: 0; color: #374151; font-size: 14px; }
  .summary { display: flex; gap: 15px; margin: 20px 0; flex-wrap: wrap; }
  .summary-card { background: #eff6ff; padding: 15px; border-radius: 6px; border-left: 4px solid #3b82f6; min-width: 150px; }
  .summary-card h3 { margin: 0 0 5px 0; font-size: 24px; color: #3b82f6; }
  .summary-card p { margin: 0; color: #6b7280; font-size: 14px; }
  .card { flex: 0 0 auto; padding: 12px 18px; border-radius: 8px; border: 1px solid; min-width: 100px; text-align: center; }
  .card .num { font-size: 24px; font-weight: 700; }
  .card .lbl { font-size: 11px; color: #5f6368; margin-top: 2px; text-transform: uppercase; letter-spacing: 0.4px; }
  .card.covered { background: #e8f7ec; border-color: #a6dbb6; }
  .card.covered .num { color: #137333; }
  .card.conflict { background: #fef7e0; border-color: #fadb87; }
  .card.conflict .num { color: #b06000; }
  .card.missing { background: #fce8e6; border-color: #f2b8b5; }
  .card.missing .num { color: #c5221f; }
  .card.extra { background: #e8f0fe; border-color: #a8c7fa; }
  .card.extra .num { color: #1967d2; }
  .card.attention { background: #f3e8fd; border-color: #d6b4fa; }
  .card.attention .num { color: #7627bb; }
  table { width: 100%; border-collapse: collapse; margin-top: 20px; table-layout: fixed; }
  th, td { padding: 12px; text-align: left; border-bottom: 1px solid #e5e7eb; word-wrap: break-word; overflow-wrap: break-word; vertical-align: top; font-size: 13px; }
  th { background-color: #f9fafb; font-weight: 600; color: #374151; position: sticky; top: 0; }
  th:nth-child(1), td:nth-child(1) { width: 28%; }
  th:nth-child(2), td:nth-child(2) { width: 20%; }
  th:nth-child(3), td:nth-child(3) { width: 12%; }
  th:nth-child(4), td:nth-child(4) { width: 10%; }
  th:nth-child(5), td:nth-child(5) { width: 15%; }
  th:nth-child(6), td:nth-child(6) { width: 15%; }
  tr:hover { background-color: #f9fafb; }
  tr.row-conflict { background: #fdecea; }
  tr.row-missing { background: #fff4e0; }
  tr.row-extra { background: #e8f1fc; }
  tr.row-covered { background: #e8f7ec; }
  tr.row-attention { background: #fff4c0; }
  tr.row-error { background: #fdecea; }
  tr.row-succeeded { }
  .pill { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
  .pill-conflict { background: #d93025; color: #fff; }
  .pill-missing { background: #f59e0b; color: #fff; }
  .pill-extra { background: #1a73e8; color: #fff; }
  .pill-covered { background: #137333; color: #fff; }
  .pill-attention{ background: #b45309; color: #fff; }
  .pill-succeeded{ background: #137333; color: #fff; }
  .pill-error { background: #d93025; color: #fff; }
  .pill-not-applicable { background: #6b7280; color: #fff; }
  tr.row-not-applicable { }
  .filters { margin: 20px 0; display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
  .filter-group { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
  .filter-label { font-weight: 600; font-size: 13px; color: #374151; margin-right: 4px; }
  .chip { display: inline-block; padding: 4px 12px; border-radius: 16px; font-size: 12px; font-weight: 500; border: 1px solid #d1d5db; background: #f3f4f6; color: #374151; cursor: pointer; transition: all 0.15s; }
  .chip.active { background: #3b82f6; color: #fff; border-color: #3b82f6; }
  .chip:hover { opacity: 0.8; }
  .result-tabs { display: flex; gap: 4px; margin: 16px 0; flex-wrap: wrap; }
  .result-tab { padding: 6px 16px; border-radius: 20px; font-size: 13px; font-weight: 500; border: 1px solid #d1d5db; background: #f9fafb; color: #374151; cursor: pointer; transition: all 0.15s; }
  .result-tab.active { background: #111827; color: #fff; border-color: #111827; }
  .result-tab:hover { opacity: 0.85; }
  .search-box { flex: 1; min-width: 200px; }
  .search-box input { width: 100%; padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; outline: none; }
  .search-box input:focus { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
  .result-count { font-size: 13px; color: #6b7280; margin: 10px 0; }
  .footer { margin-top: 40px; padding-top: 20px; border-top: 2px solid #e5e7eb; text-align: center; color: #6b7280; font-size: 14px; }
</style>
'@


    $headerCells = ($Columns | ForEach-Object { "<th>$([System.Net.WebUtility]::HtmlEncode($_))</th>" }) -join ''

    $bodyRows = foreach ($r in $Rows) {
        $status = ''
        if ($r.PSObject.Properties[$StatusColumn]) {
            $status = [string]$r.$StatusColumn
        }
        $cssStatus = $status.ToLowerInvariant() -replace '\s+', '-'
        $rowClass = "row-$cssStatus"

        $cells = foreach ($col in $Columns) {
            $val = $null
            if ($r.PSObject.Properties[$col]) { $val = $r.$col }
            if ($null -eq $val) { $val = '' }
            $encoded = [System.Net.WebUtility]::HtmlEncode([string]$val)

            if ($col -eq $StatusColumn -and $val) {
                $pillClass = "pill-$($val.ToString().ToLowerInvariant() -replace '\s+', '-')"
                "<td><span class='pill $pillClass'>$encoded</span></td>"
            } else {
                "<td>$encoded</td>"
            }
        }

        # Build data attributes for filtering
        $dataAttrs = ''
        foreach ($col in $Columns) {
            $v = ''
            if ($r.PSObject.Properties[$col]) { $v = [string]$r.$col }
            $safeVal = [System.Net.WebUtility]::HtmlEncode($v)
            $dataAttrs += " data-$($col.ToLowerInvariant())=`"$safeVal`""
        }
        "<tr class='$rowClass'$dataAttrs>$(($cells) -join '')</tr>"
    }

    $generated = (Get-Date).ToString('g')
    $count = $Rows.Count

    # Build filter chip data for columns with limited unique values (exclude Result since we have dedicated tabs)
    $candidateFilterCols = @('PolicyType', 'Status', 'User') | Where-Object { $_ -in $Columns } | Select-Object -Unique
    $filterColumns = @()
    foreach ($fc in $candidateFilterCols) {
        $uniqueVals = @($Rows | ForEach-Object { if ($_.PSObject.Properties[$fc]) { [string]$_.$fc } } | Where-Object { $_ } | Sort-Object -Unique)
        if ($uniqueVals.Count -gt 0 -and $uniqueVals.Count -le 50) { $filterColumns += $fc }
    }
    $filterChipsHtml = ''
    if ($filterColumns.Count -gt 0) {
        $filterChipsHtml = "<div class='filters'><div class='search-box'><input type='text' id='searchInput' placeholder='Search settings...' oninput='applyFilters()' /></div>"
        foreach ($fc in $filterColumns) {
            $uniqueVals = @($Rows | ForEach-Object { if ($_.PSObject.Properties[$fc]) { [string]$_.$fc } } | Where-Object { $_ } | Sort-Object -Unique)
            if ($uniqueVals.Count -gt 0) {
                $filterChipsHtml += "<div class='filter-group'><span class='filter-label'>${fc}:</span>"
                foreach ($uv in $uniqueVals) {
                    $encoded = [System.Net.WebUtility]::HtmlEncode($uv)
                    $filterChipsHtml += "<button class='chip active' data-filter='$($fc.ToLowerInvariant())' data-value='$encoded' onclick='toggleChip(this)'>$encoded</button>"
                }
                $filterChipsHtml += "</div>"
            }
        }
        $filterChipsHtml += "</div>"
    }

    $summaryHtml = ''
    if ($Summary) {
        $covered  = if ($Summary.PSObject.Properties['CoveredSettings'])  { [int]$Summary.CoveredSettings }  else { 0 }
        $conflict = if ($Summary.PSObject.Properties['ConflictSettings']) { [int]$Summary.ConflictSettings } else { 0 }
        $missing  = if ($Summary.PSObject.Properties['MissingSettings'])  { [int]$Summary.MissingSettings }  else { 0 }
        $extra    = if ($Summary.PSObject.Properties['ExtraSettings'])    { [int]$Summary.ExtraSettings }    else { 0 }
        $attention = if ($Summary.PSObject.Properties['AttentionSettings']) { [int]$Summary.AttentionSettings } else { 0 }
        $total = $Rows.Count
        $summaryHtml = @"
<div class='summary'>
  <div class='card covered'><div class='num'>$covered</div><div class='lbl'>Covered</div></div>
  <div class='card conflict'><div class='num'>$conflict</div><div class='lbl'>Conflicts</div></div>
  <div class='card missing'><div class='num'>$missing</div><div class='lbl'>Missing</div></div>
  <div class='card extra'><div class='num'>$extra</div><div class='lbl'>Extra</div></div>
  <div class='card attention'><div class='num'>$attention</div><div class='lbl'>Attention</div></div>
</div>
<div class='result-tabs'>
  <button class='result-tab active' data-result='all' onclick='filterByResult(this)'>All ($total)</button>
  <button class='result-tab' data-result='covered' onclick='filterByResult(this)'>Covered ($covered)</button>
  <button class='result-tab' data-result='conflict' onclick='filterByResult(this)'>Conflict ($conflict)</button>
  <button class='result-tab' data-result='missing' onclick='filterByResult(this)'>Missing ($missing)</button>
  <button class='result-tab' data-result='extra' onclick='filterByResult(this)'>Extra ($extra)</button>
  <button class='result-tab' data-result='attention' onclick='filterByResult(this)'>Attention ($attention)</button>
</div>
"@

    } else {
        $summaryHtml = @"
<div class='summary'>
  <div class='summary-card'><h3>$count</h3><p>Total Settings</p></div>
</div>
"@

    }

    # Extract device ID from title if present
    $infoBoxHtml = ''
    if ($Title -match '([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})') {
        $deviceId = $Matches[1]
        $infoBoxHtml = "<div class='info-box'><p><strong>Device ID:</strong> $deviceId</p></div>"
    }

    return @"
<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='utf-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<title>$($Title -replace '[<>]', '')</title>
$css
</head>
<body>
<div class='container'>
  <div class='report-header'>
    <div class='branding'>
      <div>
        <h1>IntuneDiff</h1>
        <p>Intune Policy Comparison Tool</p>
      </div>
    </div>
    <div class='header-timestamp'><strong>Report Generated:</strong><br>$generated</div>
  </div>
  <h2>$($Title -replace '&ndash;', '&#x2013;')</h2>
  $infoBoxHtml
  $summaryHtml
  $filterChipsHtml
  <div class='result-count' id='resultCount'>Showing $count of $count settings</div>
  <table>
  <thead><tr>$headerCells</tr></thead>
  <tbody id='tableBody'>
  $(($bodyRows) -join "`n")
  </tbody>
  </table>
  <div class='footer'>Generated by <strong>IntuneDiff</strong> - Intune Policy Tool - By Sandy Zeng</div>
</div>
<script>
var activeResultFilter = 'all';
function filterByResult(el) {
  document.querySelectorAll('.result-tab').forEach(function(t) { t.classList.remove('active'); });
  el.classList.add('active');
  activeResultFilter = el.getAttribute('data-result');
  applyFilters();
}
function toggleChip(el) {
  el.classList.toggle('active');
  applyFilters();
}
function applyFilters() {
  var search = (document.getElementById('searchInput') || {}).value || '';
  search = search.toLowerCase();
  var filters = {};
  document.querySelectorAll('.chip').forEach(function(c) {
    var key = c.getAttribute('data-filter');
    if (!filters[key]) filters[key] = [];
    if (c.classList.contains('active')) filters[key].push(c.getAttribute('data-value'));
  });
  var rows = document.querySelectorAll('#tableBody tr');
  var shown = 0;
  rows.forEach(function(row) {
    var visible = true;
    // Result tab filter
    if (activeResultFilter !== 'all') {
      var rowResult = (row.getAttribute('data-result') || '').toLowerCase();
      if (rowResult !== activeResultFilter) { visible = false; }
    }
    // Chip filters
    if (visible) {
      for (var key in filters) {
        if (key === 'result' && activeResultFilter !== 'all') continue;
        if (filters[key].length === 0) { visible = false; break; }
        var val = row.getAttribute('data-' + key) || '';
        if (filters[key].indexOf(val) === -1) { visible = false; break; }
      }
    }
    if (visible && search) {
      visible = row.textContent.toLowerCase().indexOf(search) !== -1;
    }
    row.style.display = visible ? '' : 'none';
    if (visible) shown++;
  });
  var total = rows.length;
  document.getElementById('resultCount').textContent = 'Showing ' + shown + ' of ' + total + ' settings';
}
</script>
</body>
</html>
"@

}