src/Private/New-ReclaimReportHtml.ps1
|
function New-ReclaimReportHtml { <# .SYNOPSIS Renders the scan report to a self-contained HTML file. .DESCRIPTION Free mode shows the headline number and the by-SKU rollup, then a locked call-to-action in place of the named accounts. Full mode renders the full per-account remediation table. Styling is inline and deliberately enterprise-restrained (navy + muted teal) to match the Optera AI vendor site - no gradient/AI-slop look. .OUTPUTS The path written. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object] $Report, [Parameter(Mandatory)] [string] $Path ) function _enc([object]$v) { [System.Net.WebUtility]::HtmlEncode([string]$v) } $s = $Report.Summary $generated = '{0:yyyy-MM-dd HH:mm} UTC' -f $Report.GeneratedAt.ToUniversalTime() $skuRows = ($s.BySku | ForEach-Object { '<tr><td>{0}</td><td class="num">{1}</td><td class="num">${2:N2}</td><td class="num">${3:N2}</td></tr>' -f ` (_enc $_.Name), $_.Count, $_.MonthlyEachUsd, $_.MonthlyTotalUsd }) -join "`n" # Owned-vs-reclaimable table when tenant inventory (subscribedSkus) is available; otherwise the # plain by-type rollup. Both are aggregate (no per-account data) so they show in Free and Full. if ($Report.Inventory -and @($Report.Inventory).Count) { $invRows = ($Report.Inventory | ForEach-Object { $owned = if ($null -ne $_.PrepaidUnits) { '{0:N0}' -f $_.PrepaidUnits } else { '—' } $assigned = if ($null -ne $_.ConsumedUnits) { '{0:N0}' -f $_.ConsumedUnits } else { '—' } '<tr><td>{0}</td><td class="num">{1}</td><td class="num">{2}</td><td class="num">{3}</td><td class="num">${4:N2}</td></tr>' -f ` (_enc $_.Name), $owned, $assigned, $_.ReclaimableUnits, $_.ReclaimableMonthlyUsd }) -join "`n" $byTypeSection = @" <h2>Reclaimable licenses by type</h2> <p class="note">A breakdown of the reclaimable licenses above. <strong>Owned</strong> and <strong>Assigned</strong> are your tenant's prepaid and consumed seats for each license; <strong>On dead/stale</strong> are the seats still assigned to accounts that are disabled or inactive on-premises.</p> <table class="grid"> <thead><tr><th>License</th><th class="num">Owned</th><th class="num">Assigned</th><th class="num">On dead/stale</th><th class="num">`$/mo</th></tr></thead> <tbody> $invRows </tbody> </table> "@ } else { $byTypeSection = @" <h2>By license type</h2> <table class="grid"> <thead><tr><th>License</th><th class="num">Count</th><th class="num">List `$/mo each</th><th class="num">`$/mo total</th></tr></thead> <tbody> $skuRows </tbody> </table> "@ } if ($Report.Mode -eq 'Full') { $accountRows = ($Report.Reclaimable | Sort-Object MonthlyWasteUsd -Descending | ForEach-Object { $skus = ($_.Skus | ForEach-Object { _enc $_.Name }) -join ', ' '<tr><td>{0}</td><td>{1}</td><td>{2}</td><td>{3}</td><td>{4}</td><td class="num">${5:N2}</td></tr>' -f ` (_enc $_.DisplayName), (_enc $_.UserPrincipalName), (_enc $_.Reason), (_enc $_.OrgUnit), (_enc $skus), $_.MonthlyWasteUsd }) -join "`n" $accountSection = @" <h2>Accounts to remediate ($($s.ReclaimableAccountCount))</h2> <table class="grid"> <thead><tr><th>Name</th><th>UPN</th><th>Why</th><th>OU</th><th>Licenses</th><th class="num">$/mo</th></tr></thead> <tbody> $accountRows </tbody> </table> "@ } else { $accountSection = @" <div class="locked"> <h2>$($s.ReclaimableAccountCount) accounts are named in the full report</h2> <p>The free report shows what you are losing. The <strong>`$79 unlock</strong> reveals exactly <em>which</em> accounts to deprovision (name, UPN, OU, licenses) and exports the remediation CSV.</p> <p><a href="https://opteraai.com/products">Unlock the remediation list →</a></p> </div> "@ } $unknownNote = if ($s.UnknownPriceSkuCount -gt 0) { "<p class=`"note`">$($s.UnknownPriceSkuCount) license(s) had no price in the list and were counted as `$0. Supply a custom price list to include them.</p>" } else { '' } $html = @" <!doctype html> <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> <title>License Reclaim Report · Optera</title> <style> :root { --navy:#0f2740; --teal:#2f7e7a; --ink:#1c2733; --muted:#6b7785; --line:#e3e8ee; --bg:#f7f9fb; } * { box-sizing:border-box; } body { font-family:Georgia,'Times New Roman',serif; color:var(--ink); background:var(--bg); margin:0; padding:0; } .wrap { max-width:960px; margin:0 auto; padding:40px 28px 64px; } header { border-bottom:3px solid var(--navy); padding-bottom:14px; margin-bottom:8px; } .brand { font-family:'Segoe UI',Arial,sans-serif; letter-spacing:.04em; color:var(--navy); font-weight:600; font-size:14px; text-transform:uppercase; } .meta { font-family:'Segoe UI',Arial,sans-serif; color:var(--muted); font-size:13px; margin-top:4px; } .headline { background:var(--navy); color:#fff; border-radius:8px; padding:28px 30px; margin:24px 0; } .headline .money { font-family:'Segoe UI',Arial,sans-serif; font-size:42px; font-weight:700; line-height:1; } .headline .sub { font-family:'Segoe UI',Arial,sans-serif; color:#b9c6d6; margin-top:10px; font-size:15px; } h2 { font-family:'Segoe UI',Arial,sans-serif; color:var(--navy); font-size:18px; margin:32px 0 12px; } table.grid { width:100%; border-collapse:collapse; font-family:'Segoe UI',Arial,sans-serif; font-size:14px; } table.grid th { text-align:left; background:#eef2f6; color:var(--navy); padding:9px 12px; border-bottom:2px solid var(--line); } table.grid td { padding:8px 12px; border-bottom:1px solid var(--line); } table.grid td.num, table.grid th.num { text-align:right; font-variant-numeric:tabular-nums; } .locked { border:1px dashed var(--teal); background:#f0f7f6; border-radius:8px; padding:24px 28px; margin-top:18px; font-family:'Segoe UI',Arial,sans-serif; } .locked a { color:var(--teal); font-weight:600; text-decoration:none; } .note { font-family:'Segoe UI',Arial,sans-serif; color:var(--muted); font-size:13px; } footer { margin-top:40px; padding-top:16px; border-top:1px solid var(--line); font-family:'Segoe UI',Arial,sans-serif; color:var(--muted); font-size:12px; } </style></head> <body><div class="wrap"> <header> <div class="brand">Optera · LicenseReclaim</div> <div class="meta">Tenant $(_enc $Report.TenantId) • Generated $generated • Stale threshold $($Report.StaleDays) days • $($Report.Mode) report</div> </header> <div class="headline"> <div class="money">`$$('{0:N0}' -f $s.TotalMonthlyUsd)/mo <span style="font-size:22px;font-weight:400">(`$$('{0:N0}' -f $s.TotalAnnualUsd)/yr)</span></div> <div class="sub">$($s.ReclaimableAccountCount) dead or stale accounts still holding $($s.ReclaimableLicenseCount) paid Microsoft 365 licenses — <strong>review candidates</strong>, verify before reclaiming.</div> </div> $byTypeSection $accountSection $unknownNote <h2>Before you act</h2> <ul class="note"> <li><strong>"Stale" is an on-premises signal.</strong> It measures AD logon age, not Microsoft 365 sign-in, so a cloud-active user who rarely authenticates on-prem can appear here. Confirm against Entra sign-in activity before reclaiming.</li> <li><strong>Disabled + licensed can be deliberate.</strong> Litigation-hold and shared/forwarding mailboxes are sometimes licensed on purpose. Suppress those OUs with <code>-ExcludeOu</code>.</li> <li><strong>Matching can undercount.</strong> Accounts with alternate UPN suffixes or no synced anchor may be missed — a reclaim candidate can be omitted, but a false positive is never invented.</li> </ul> <footer> $(_enc $Report.Disclaimer) These figures are review candidates, not confirmed waste.<br> A product of Optera AI LLC · https://opteraai.com </footer> </div></body></html> "@ $dir = Split-Path -Parent $Path if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $html | Set-Content -LiteralPath $Path -Encoding UTF8 return $Path } |