Private/New-HtmlDashboard.ps1

function New-HtmlDashboard {
    <#
    .SYNOPSIS
        Generates an HTML dashboard report with a dark theme and gold accent.
    .DESCRIPTION
        Creates a self-contained HTML file with license optimization results displayed
        in a dark-themed dashboard. Features a prominent savings summary card at the
        top with estimated annual savings in green text, followed by data tables for
        license inventory, underutilized licenses, inactive users, and savings breakdown.
    .PARAMETER Title
        Dashboard title displayed in the header.
    .PARAMETER LicenseInventory
        Collection of license inventory objects from Get-LicenseInventory.
    .PARAMETER UnderutilizedLicenses
        Collection of underutilized license objects from Get-UnderutilizedLicenses.
    .PARAMETER InactiveUsers
        Collection of inactive licensed user objects from Get-InactiveLicensedUsers.
    .PARAMETER SavingsReport
        Collection of savings summary objects from Get-LicenseSavingsReport.
    .PARAMETER OutputPath
        Full path to the output HTML file.
    .OUTPUTS
        System.IO.FileInfo
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [string]$Title = 'M365 License Optimization Report',

        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [object[]]$LicenseInventory,

        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [object[]]$UnderutilizedLicenses,

        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [object[]]$InactiveUsers,

        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [object[]]$SavingsReport,

        [Parameter(Mandatory = $true)]
        [string]$OutputPath
    )

    # Calculate headline savings figure
    $annualSavings = 0
    if ($SavingsReport) {
        $annualSavings = ($SavingsReport | Measure-Object -Property AnnualSavings -Sum -ErrorAction SilentlyContinue).Sum
        if (-not $annualSavings) { $annualSavings = 0 }
    }

    $totalMonthlySpend = 0
    if ($SavingsReport) {
        $spendRow = $SavingsReport | Where-Object { $_.Category -eq 'Current Spend' }
        if ($spendRow) {
            $totalMonthlySpend = $spendRow.MonthlySavings
        }
    }

    $generatedDate = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'

    # Build HTML table helper
    function ConvertTo-HtmlTable {
        param([object[]]$Data, [string[]]$Properties)
        if (-not $Data -or $Data.Count -eq 0) {
            return '<p class="empty">No data available.</p>'
        }
        $sb = [System.Text.StringBuilder]::new()
        [void]$sb.Append('<div class="table-wrapper"><table><thead><tr>')
        foreach ($prop in $Properties) {
            [void]$sb.Append("<th>$prop</th>")
        }
        [void]$sb.Append('</tr></thead><tbody>')
        foreach ($row in $Data) {
            [void]$sb.Append('<tr>')
            foreach ($prop in $Properties) {
                $value = $row.$prop
                if ($null -eq $value) { $value = '' }
                # Format currency values
                if ($prop -match 'Cost|Savings|Spend' -and $value -is [double]) {
                    $value = '${0:N2}' -f $value
                }
                if ($prop -match 'Percent') {
                    $value = '{0:N1}%' -f $value
                }
                # Highlight findings
                $class = ''
                if ($prop -eq 'Finding') {
                    switch -Wildcard ($value) {
                        'OVER-PROVISIONED*'     { $class = ' class="warn"' }
                        'DOWNGRADE*'            { $class = ' class="warn"' }
                        'NO SIGN-IN*'           { $class = ' class="alert"' }
                        'INACTIVE*'             { $class = ' class="alert"' }
                        'DISABLED*'             { $class = ' class="alert"' }
                        'NEVER SIGNED IN*'      { $class = ' class="alert"' }
                        'GUEST*'                { $class = ' class="warn"' }
                        'FULLY UTILIZED*'       { $class = ' class="good"' }
                    }
                }
                [void]$sb.Append("<td$class>$([System.Web.HttpUtility]::HtmlEncode($value))</td>")
            }
            [void]$sb.Append('</tr>')
        }
        [void]$sb.Append('</tbody></table></div>')
        return $sb.ToString()
    }

    # Build section tables
    $inventoryHtml = ConvertTo-HtmlTable -Data $LicenseInventory -Properties @(
        'SkuPartNumber', 'FriendlyName', 'TotalLicenses', 'AssignedLicenses',
        'AvailableLicenses', 'UtilizationPercent', 'MonthlyCost', 'Finding'
    )

    $underutilizedHtml = ConvertTo-HtmlTable -Data $UnderutilizedLicenses -Properties @(
        'UserPrincipalName', 'DisplayName', 'AssignedLicense', 'LastSignIn',
        'DaysSinceSignIn', 'ServicesUsed', 'RecommendedLicense', 'MonthlySavings', 'Finding'
    )

    $inactiveHtml = ConvertTo-HtmlTable -Data $InactiveUsers -Properties @(
        'UserPrincipalName', 'DisplayName', 'AccountEnabled', 'UserType',
        'AssignedLicenses', 'LicenseCost', 'LastSignIn', 'DaysSinceSignIn', 'Finding'
    )

    $savingsHtml = ConvertTo-HtmlTable -Data $SavingsReport -Properties @(
        'Category', 'Action', 'Count', 'MonthlySavings', 'AnnualSavings'
    )

    $html = @"
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>$([System.Web.HttpUtility]::HtmlEncode($Title))</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: #1a1a2e;
            color: #e0e0e0;
            padding: 20px;
            line-height: 1.6;
        }
        .header {
            text-align: center;
            padding: 30px 20px;
            border-bottom: 3px solid #d29922;
            margin-bottom: 30px;
        }
        .header h1 {
            color: #d29922;
            font-size: 2em;
            margin-bottom: 5px;
        }
        .header .subtitle {
            color: #888;
            font-size: 0.9em;
        }
        .savings-card {
            background: linear-gradient(135deg, #1e3a2f 0%, #1a2e1a 100%);
            border: 2px solid #2ecc71;
            border-radius: 12px;
            padding: 30px;
            text-align: center;
            margin-bottom: 30px;
            box-shadow: 0 4px 20px rgba(46, 204, 113, 0.15);
        }
        .savings-card .label {
            font-size: 1.1em;
            color: #a0a0a0;
            text-transform: uppercase;
            letter-spacing: 2px;
            margin-bottom: 10px;
        }
        .savings-card .amount {
            font-size: 3.5em;
            font-weight: 700;
            color: #2ecc71;
            text-shadow: 0 0 20px rgba(46, 204, 113, 0.3);
        }
        .savings-card .detail {
            font-size: 0.95em;
            color: #888;
            margin-top: 10px;
        }
        .section {
            background: #16213e;
            border-radius: 10px;
            padding: 25px;
            margin-bottom: 25px;
            border-left: 4px solid #d29922;
        }
        .section h2 {
            color: #d29922;
            margin-bottom: 15px;
            font-size: 1.3em;
        }
        .section .count {
            color: #888;
            font-size: 0.85em;
            margin-bottom: 15px;
        }
        .table-wrapper {
            overflow-x: auto;
        }
        table {
            width: 100%;
            border-collapse: collapse;
            font-size: 0.85em;
        }
        th {
            background: #0f3460;
            color: #d29922;
            padding: 10px 12px;
            text-align: left;
            font-weight: 600;
            white-space: nowrap;
            border-bottom: 2px solid #d29922;
        }
        td {
            padding: 8px 12px;
            border-bottom: 1px solid #1a1a3e;
        }
        tr:hover { background: #1a2a4e; }
        td.warn { color: #f39c12; font-weight: 600; }
        td.alert { color: #e74c3c; font-weight: 600; }
        td.good { color: #2ecc71; font-weight: 600; }
        .empty {
            color: #666;
            font-style: italic;
            padding: 20px;
            text-align: center;
        }
        .footer {
            text-align: center;
            color: #555;
            font-size: 0.8em;
            margin-top: 30px;
            padding-top: 20px;
            border-top: 1px solid #333;
        }
        @media (max-width: 768px) {
            .savings-card .amount { font-size: 2.2em; }
            body { padding: 10px; }
        }
    </style>
</head>
<body>
    <div class="header">
        <h1>$([System.Web.HttpUtility]::HtmlEncode($Title))</h1>
        <div class="subtitle">Generated $generatedDate | M365-LicenseOptimizer</div>
    </div>
 
    <div class="savings-card">
        <div class="label">Estimated Annual Savings</div>
        <div class="amount">$('${0:N0}' -f $annualSavings)</div>
        <div class="detail">Based on list pricing estimates. Actual savings may vary by agreement type (EA/CSP/MOSP).</div>
    </div>
 
    <div class="section">
        <h2>Savings Summary</h2>
        <div class="count">Breakdown of optimization opportunities</div>
        $savingsHtml
    </div>
 
    <div class="section">
        <h2>License Inventory</h2>
        <div class="count">$( if ($LicenseInventory) { "$($LicenseInventory.Count) SKU(s)" } else { '0 SKUs' } )</div>
        $inventoryHtml
    </div>
 
    <div class="section">
        <h2>Underutilized Licenses</h2>
        <div class="count">$( if ($UnderutilizedLicenses) { "$($UnderutilizedLicenses.Count) user(s) identified" } else { '0 users' } )</div>
        $underutilizedHtml
    </div>
 
    <div class="section">
        <h2>Inactive Licensed Users</h2>
        <div class="count">$( if ($InactiveUsers) { "$($InactiveUsers.Count) user(s) identified" } else { '0 users' } )</div>
        $inactiveHtml
    </div>
 
    <div class="footer">
        M365-LicenseOptimizer &copy; 2026 Larry Roberts | Read-only analysis &mdash; no changes made to your tenant
    </div>
</body>
</html>
"@


    # Ensure output directory exists
    $outputDir = Split-Path -Path $OutputPath -Parent
    if ($outputDir -and -not (Test-Path -Path $outputDir)) {
        New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
    }

    $html | Out-File -FilePath $OutputPath -Encoding UTF8 -Force
    Write-Verbose "Dashboard written to $OutputPath"
    return (Get-Item -Path $OutputPath)
}