Public/Invoke-ModernMailContactGroupReport.ps1

function Invoke-ModernMailContactGroupReport {
    <#
    .SYNOPSIS
        Generates a contact-centric distribution group membership report.
    .DESCRIPTION
        Reads mail contacts (and optionally mail users and guests) from Exchange Online and resolves which distribution lists, mail-enabled security groups, room lists and (optionally) dynamic distribution groups they belong to. Nested memberships can be expanded so you also see the parent groups a contact receives mail through. Contacts without any group membership are listed as well, which helps with cleanup. Supports optional export to CSV, JSON or HTML.
    .PARAMETER OutPath
        Optional path for exporting the report. Provide a folder to create MailContactGroupReport.csv or a specific file path ending in .csv, .json or .html.
    .PARAMETER RecipientType
        Which recipient types to analyze. Defaults to MailContact.
    .PARAMETER IncludeNested
        Resolve nested group membership (contact is in group A, group A is member of group B).
    .PARAMETER IncludeDynamic
        Include dynamic distribution groups (uses Get-DynamicDistributionGroupMember, slower in large tenants).
    .PARAMETER UnassignedOnly
        Return only contacts that are not member of any group.
    .PARAMETER Raw
        Return one row per contact/group assignment instead of the per-contact summary.
    .PARAMETER AddHtmlOutput
        Emit an HTML report (MailContactGroupReport.html) next to the CSV/JSON export.
    .PARAMETER DisableTelemetry
        Disable telemetry.
    .EXAMPLE
        Invoke-ModernMailContactGroupReport
    .EXAMPLE
        Invoke-ModernMailContactGroupReport -OutPath C:\Reports
    .EXAMPLE
        Invoke-ModernMailContactGroupReport -IncludeNested -IncludeDynamic -OutPath C:\Reports\MailContactGroupReport.html
    .EXAMPLE
        Invoke-ModernMailContactGroupReport -RecipientType MailContact,MailUser,GuestMailUser -Raw -OutPath C:\Reports\contacts.json
    .EXAMPLE
        Invoke-ModernMailContactGroupReport -UnassignedOnly | Format-Table DisplayName, ExternalEmailAddress
    .LINK
        https://exchangepermissions.alweys.ch/modernmailtools/Invoke-ModernMailContactGroupReport
    #>


    [CmdletBinding()]
    [OutputType([System.Management.Automation.PSCustomObject])]
    param(
        [Parameter(Mandatory = $false, HelpMessage = 'Optional export path (folder, .csv, .json or .html).')]
        [string]$OutPath,

        [Parameter(Mandatory = $false, HelpMessage = 'Recipient types to analyze')]
        [ValidateSet('MailContact', 'MailUser', 'GuestMailUser')]
        [string[]]$RecipientType = @('MailContact'),

        [Parameter(Mandatory = $false, HelpMessage = 'Resolve nested group membership')]
        [switch]$IncludeNested,

        [Parameter(Mandatory = $false, HelpMessage = 'Include dynamic distribution groups')]
        [switch]$IncludeDynamic,

        [Parameter(Mandatory = $false, HelpMessage = 'Return only contacts without group membership')]
        [switch]$UnassignedOnly,

        [Parameter(Mandatory = $false, HelpMessage = 'Return detailed rows instead of summary')]
        [switch]$Raw,

        [Parameter(Mandatory = $false, HelpMessage = 'Emit an HTML report (MailContactGroupReport.html)')]
        [switch]$AddHtmlOutput,

        [Parameter(Mandatory = $false, HelpMessage = 'Disable telemetry')]
        [switch]$DisableTelemetry
    )

    if ($DisableTelemetry -or $Script:DisableTelemetry) {
        $Script:DisableTelemetry = $true
    } else {
        Write-Telemetry -EventName "InvokeModernMailContactGroupReport"
    }

    # Ensure shared session cache exists to keep compatibility with other functions.
    if (-not (Get-Variable -Name __MMTSession -Scope Script -ErrorAction SilentlyContinue)) {
        $script:__MMTSession = @{
            CloudUserCache = @()
            CloudMailboxCache = @()
            CountCache = @{}
            TableCache = @{}
        }
        New-Variable -Name __MMTSession -Value $script:__MMTSession -Scope Script -Force | Out-Null
    }

    # Exchange Online session
    $session = Get-ConnectionInformation | Where-Object { $_.state -eq 'Connected' -and $_.ModulePrefix -eq 'EO' }
    if (-not $session) {
        $session = Connect-MMExchangeOnline
    }

    function ConvertTo-MMHtmlText {
        param([object]$Value)
        return [System.Net.WebUtility]::HtmlEncode([string]$Value)
    }

    function Get-MMGroupTypeLabel {
        param([string]$TypeDetails)
        switch ($TypeDetails) {
            'MailUniversalDistributionGroup' { 'Distribution' }
            'MailNonUniversalGroup' { 'Distribution' }
            'MailUniversalSecurityGroup' { 'MailSecurity' }
            'RoomList' { 'RoomList' }
            'DynamicDistributionGroup' { 'Dynamic' }
            default { $TypeDetails }
        }
    }

    function Get-MMAddress {
        param([object]$Value)
        if ($null -eq $Value) { return $null }
        return ($Value.ToString() -replace '^(?i)smtp:', '')
    }

    $groupTypes = @('MailUniversalDistributionGroup', 'MailNonUniversalGroup', 'MailUniversalSecurityGroup', 'RoomList', 'DynamicDistributionGroup')

    #region Contacts
    $contactIndex = @{}

    if ($RecipientType -contains 'MailContact') {
        foreach ($c in (Get-EOMailContact -ResultSize unlimited)) {
            $contactIndex[$c.Guid.ToString()] = [pscustomobject]@{
                Guid = $c.Guid.ToString()
                DisplayName = $c.DisplayName
                ExternalEmailAddress = Get-MMAddress $c.ExternalEmailAddress
                PrimarySmtpAddress = Get-MMAddress $c.PrimarySmtpAddress
                RecipientTypeDetails = $c.RecipientTypeDetails.ToString()
                IsDirSynced = [bool]$c.IsDirSynced
                HiddenFromAddressListsEnabled = [bool]$c.HiddenFromAddressListsEnabled
                WhenCreated = $c.WhenCreated
            }
        }
    }

    if ($RecipientType -contains 'MailUser' -or $RecipientType -contains 'GuestMailUser') {
        foreach ($u in (Get-EOMailUser -ResultSize unlimited)) {
            if ($RecipientType -notcontains $u.RecipientTypeDetails.ToString()) { continue }
            $contactIndex[$u.Guid.ToString()] = [pscustomobject]@{
                Guid = $u.Guid.ToString()
                DisplayName = $u.DisplayName
                ExternalEmailAddress = Get-MMAddress $u.ExternalEmailAddress
                PrimarySmtpAddress = Get-MMAddress $u.PrimarySmtpAddress
                RecipientTypeDetails = $u.RecipientTypeDetails.ToString()
                IsDirSynced = [bool]$u.IsDirSynced
                HiddenFromAddressListsEnabled = [bool]$u.HiddenFromAddressListsEnabled
                WhenCreated = $u.WhenCreated
            }
        }
    }

    Write-Verbose "Contacts to analyze: $($contactIndex.Count)"
    #endregion

    #region Groups and membership
    $groups = @(Get-MMCloudDistributionGroup)
    if ($IncludeDynamic) {
        $groups += @(Get-EODynamicDistributionGroup -ResultSize unlimited)
    }

    $groupIndex = @{}
    foreach ($g in $groups) {
        $groupIndex[$g.Guid.ToString()] = [pscustomobject]@{
            Guid = $g.Guid.ToString()
            DisplayName = $g.DisplayName
            PrimarySmtpAddress = Get-MMAddress $g.PrimarySmtpAddress
            GroupType = Get-MMGroupTypeLabel $g.RecipientTypeDetails.ToString()
            RequireSenderAuthenticationEnabled = [bool]$g.RequireSenderAuthenticationEnabled
        }
    }

    # contactGuid|groupGuid -> assignment row
    $assignments = [ordered]@{}
    # childGroupGuid -> list of parent group guids
    $parentMap = @{}

    $i = 0
    foreach ($g in $groups) {
        $i++
        $gKey = $g.Guid.ToString()
        Write-Progress -Activity 'Reading group membership' -Status $g.DisplayName -PercentComplete (($i / [math]::Max($groups.Count, 1)) * 100)

        try {
            if ($g.RecipientTypeDetails.ToString() -eq 'DynamicDistributionGroup') {
                $members = Get-EODynamicDistributionGroupMember -Identity $g.Guid.ToString() -ResultSize unlimited -ErrorAction Stop
            } else {
                $members = Get-EODistributionGroupMember -Identity $g.Guid.ToString() -ResultSize unlimited -ErrorAction Stop
            }
        } catch {
            Write-Verbose "Member query failed for $($g.PrimarySmtpAddress): $($_.Exception.Message)"
            continue
        }

        foreach ($m in $members) {
            $mType = [string]$m.RecipientTypeDetails
            $mKey = [string]$m.Guid

            if ($RecipientType -contains $mType) {
                if (-not $contactIndex.ContainsKey($mKey)) {
                    # Member not returned by the contact query (e.g. filtered); add a minimal entry.
                    $contactIndex[$mKey] = [pscustomobject]@{
                        Guid = $mKey
                        DisplayName = $m.DisplayName
                        ExternalEmailAddress = Get-MMAddress $m.ExternalEmailAddress
                        PrimarySmtpAddress = Get-MMAddress $m.PrimarySmtpAddress
                        RecipientTypeDetails = $mType
                        IsDirSynced = [bool]$m.IsDirSynced
                        HiddenFromAddressListsEnabled = [bool]$m.HiddenFromAddressListsEnabled
                        WhenCreated = $m.WhenCreated
                    }
                }
                $assignments["$mKey|$gKey"] = [pscustomobject]@{
                    ContactGuid = $mKey
                    GroupGuid = $gKey
                    MembershipType = 'Direct'
                    Via = $null
                }
            } elseif ($IncludeNested -and $groupTypes -contains $mType) {
                if (-not $parentMap.ContainsKey($mKey)) { $parentMap[$mKey] = [System.Collections.Generic.List[string]]::new() }
                $parentMap[$mKey].Add($gKey)
            }
        }
    }
    Write-Progress -Activity 'Reading group membership' -Completed

    if ($IncludeNested) {
        $directRows = @($assignments.Values | Where-Object { $_.MembershipType -eq 'Direct' })
        foreach ($row in $directRows) {
            $visited = @{ $row.GroupGuid = $true }
            $queue = [System.Collections.Generic.Queue[object]]::new()
            $queue.Enqueue(@($row.GroupGuid, @($groupIndex[$row.GroupGuid].DisplayName)))

            while ($queue.Count -gt 0) {
                $item = $queue.Dequeue()
                $childKey = $item[0]
                $chain = $item[1]
                if (-not $parentMap.ContainsKey($childKey)) { continue }

                foreach ($parentKey in $parentMap[$childKey]) {
                    if ($visited.ContainsKey($parentKey)) { continue }
                    $visited[$parentKey] = $true

                    $aKey = "$($row.ContactGuid)|$parentKey"
                    if (-not $assignments.Contains($aKey)) {
                        $assignments[$aKey] = [pscustomobject]@{
                            ContactGuid = $row.ContactGuid
                            GroupGuid = $parentKey
                            MembershipType = 'Nested'
                            Via = ($chain -join ' > ')
                        }
                    }
                    $queue.Enqueue(@($parentKey, @($chain + $groupIndex[$parentKey].DisplayName)))
                }
            }
        }
    }
    #endregion

    #region Output
    $expandedRows = foreach ($a in $assignments.Values) {
        $c = $contactIndex[$a.ContactGuid]
        $g = $groupIndex[$a.GroupGuid]

        [pscustomobject]@{
            DisplayName = $c.DisplayName
            ExternalEmailAddress = $c.ExternalEmailAddress
            RecipientTypeDetails = $c.RecipientTypeDetails
            Group = $g.DisplayName
            GroupSmtpAddress = $g.PrimarySmtpAddress
            GroupType = $g.GroupType
            MembershipType = $a.MembershipType
            Via = $a.Via
        }
    }
    $expandedRows = @($expandedRows | Sort-Object DisplayName, Group)

    $rowsByContact = @{}
    foreach ($a in $assignments.Values) {
        if (-not $rowsByContact.ContainsKey($a.ContactGuid)) { $rowsByContact[$a.ContactGuid] = [System.Collections.Generic.List[object]]::new() }
        $rowsByContact[$a.ContactGuid].Add($a)
    }

    $summary = foreach ($c in $contactIndex.Values) {
        $rows = if ($rowsByContact.ContainsKey($c.Guid)) { @($rowsByContact[$c.Guid]) } else { @() }

        [pscustomobject]@{
            DisplayName = $c.DisplayName
            ExternalEmailAddress = $c.ExternalEmailAddress
            PrimarySmtpAddress = $c.PrimarySmtpAddress
            RecipientTypeDetails = $c.RecipientTypeDetails
            IsDirSynced = $c.IsDirSynced
            HiddenFromAddressListsEnabled = $c.HiddenFromAddressListsEnabled
            WhenCreated = $c.WhenCreated
            DirectGroupCount = @($rows | Where-Object { $_.MembershipType -eq 'Direct' }).Count
            TotalGroupCount = $rows.Count
            Groups = (@($rows | ForEach-Object {
                        $g = $groupIndex[$_.GroupGuid]
                        "$($g.DisplayName) <$($g.PrimarySmtpAddress)> [$($_.MembershipType)]"
                    } | Sort-Object) -join '; ')
        }
    }
    $summary = @($summary | Sort-Object DisplayName)
    $unassigned = @($summary | Where-Object { $_.TotalGroupCount -eq 0 })

    if ($UnassignedOnly) {
        $output = $unassigned
    } elseif ($Raw) {
        $output = $expandedRows
    } else {
        $output = $summary
    }

    if ($OutPath) {
        $targetPath = $OutPath

        if (-not [System.IO.Path]::HasExtension($targetPath)) {
            $targetPath = Join-Path -Path $OutPath -ChildPath 'MailContactGroupReport.csv'
        }

        $dir = Split-Path -Path $targetPath -Parent
        if ($dir -and -not (Test-Path -Path $dir -PathType Container)) {
            New-Item -ItemType Directory -Path $dir -Force | Out-Null
        }

        if ($targetPath.ToLower().EndsWith('.json')) {
            $output | ConvertTo-Json -Depth 6 | Set-Content -Path $targetPath -Encoding UTF8
            Write-Verbose "Report exported to $targetPath"
        } elseif (-not $targetPath.ToLower().EndsWith('.html')) {
            $output | Export-Csv -Path $targetPath -NoTypeInformation -Encoding UTF8
            Write-Verbose "Report exported to $targetPath"
        }

        if ($targetPath.ToLower().EndsWith('.html') -or $AddHtmlOutput) {
            $htmlPath = if ($targetPath.ToLower().EndsWith('.html')) { $targetPath } else { Join-Path -Path (Split-Path $targetPath -Parent) -ChildPath 'MailContactGroupReport.html' }

            $htmlTitle = 'Mail Contact Group Report'
            $htmlDate = Get-Date
            $tenantId = if ($session.TenantId) { $session.TenantId } else { 'N/A' }
            $generatedBy = if ($session.UserPrincipalName) { $session.UserPrincipalName } else { 'N/A' }
            $exoConnected = [bool]$session

            $contactCount = $summary.Count
            $assignedCount = $contactCount - $unassigned.Count
            $unassignedCount = $unassigned.Count
            $groupsWithContacts = ($expandedRows | Select-Object -ExpandProperty GroupSmtpAddress -Unique | Measure-Object).Count

            $typeStats = $expandedRows | Group-Object GroupType | Sort-Object Name
            $chartLabelsString = "[" + (($typeStats | ForEach-Object { "'$($_.Name)'" }) -join ',') + "]"
            $chartDataString = "[" + (($typeStats | ForEach-Object { $_.Count }) -join ',') + "]"

            $topGroups = $expandedRows | Group-Object GroupSmtpAddress | Sort-Object Count -Descending | Select-Object -First 15

$page = @"
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>$htmlTitle</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body class="bg-gray-100 text-gray-800">
    <div class="container mx-auto p-6">
        <div class="bg-orange-500 text-white rounded-lg shadow-md p-6 mb-6">
            <div class="flex flex-col gap-2">
                <h1 class="text-3xl font-bold">$htmlTitle</h1>
                <p class="text-lg">Tenant ID: $tenantId</p>
                <p class="text-lg">Generated by: $generatedBy</p>
                <p class="text-sm opacity-90">Generated on: $htmlDate</p>
                <div class="mt-3 flex flex-wrap gap-2 text-xs font-semibold">
                    <a href="https://www.alweys.ch" class="bg-blue-500 hover:bg-blue-700 text-white px-3 py-1 rounded shadow-sm">Visit Alweys Website (Blog)</a>
                    <a href="https://exchangepermissions.alweys.ch/modernmailtools/Invoke-ModernMailContactGroupReport" class="bg-blue-500 hover:bg-blue-700 text-white px-3 py-1 rounded shadow-sm">Documentation for Cmdlet (Help)</a>
                    <a href="https://www.linkedin.com/in/stefanwey" class="bg-blue-500 hover:bg-blue-700 text-white px-3 py-1 rounded shadow-sm">Connect on LinkedIn (Stefan Wey)</a>
                    <a href="#" class="bg-gray-500 hover:bg-gray-700 text-white px-3 py-1 rounded shadow-sm">Exchange Online: $exoConnected</a>
                    <a href="#" class="bg-gray-500 hover:bg-gray-700 text-white px-3 py-1 rounded shadow-sm">Nested: $([bool]$IncludeNested) / Dynamic: $([bool]$IncludeDynamic)</a>
                </div>
            </div>
        </div>
 
    <div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
      <div class="bg-white rounded-lg shadow p-4">
        <div class="text-sm text-gray-500">Contacts analyzed</div>
        <div class="text-2xl font-semibold text-orange-500">$contactCount</div>
      </div>
      <div class="bg-white rounded-lg shadow p-4">
        <div class="text-sm text-gray-500">Contacts in groups</div>
        <div class="text-2xl font-semibold text-orange-500">$assignedCount</div>
      </div>
      <div class="bg-white rounded-lg shadow p-4">
        <div class="text-sm text-gray-500">Contacts without group</div>
        <div class="text-2xl font-semibold text-orange-500">$unassignedCount</div>
      </div>
      <div class="bg-white rounded-lg shadow p-4">
        <div class="text-sm text-gray-500">Groups with contacts</div>
        <div class="text-2xl font-semibold text-orange-500">$groupsWithContacts</div>
      </div>
    </div>
 
    <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
      <div class="bg-white rounded-lg shadow p-4 max-w-lg">
        <h2 class="text-xl font-semibold mb-3">Assignments by group type</h2>
        <canvas id="typeChart" class="w-72 h-72 mx-auto"></canvas>
      </div>
      <div class="bg-white rounded-lg shadow p-4 overflow-x-auto">
        <h2 class="text-xl font-semibold mb-3">Top groups by contact count</h2>
        <table class="min-w-full table-auto text-sm border-collapse">
          <thead class="bg-gray-50 text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200">
            <tr>
              <th class="px-3 py-2 text-left">Group</th>
              <th class="px-3 py-2 text-left">Type</th>
              <th class="px-3 py-2 text-left">Contacts</th>
            </tr>
          </thead>
          <tbody>
"@


            foreach ($tg in $topGroups) {
                $first = $tg.Group | Select-Object -First 1
$page += @"
            <tr class="odd:bg-white even:bg-gray-50 hover:bg-amber-50 border-b border-gray-100">
              <td class="px-3 py-2 whitespace-nowrap">$(ConvertTo-MMHtmlText $first.Group)<br><span class="text-xs text-gray-500">$(ConvertTo-MMHtmlText $tg.Name)</span></td>
              <td class="px-3 py-2 whitespace-nowrap">$($first.GroupType)</td>
              <td class="px-3 py-2 whitespace-nowrap">$($tg.Count)</td>
            </tr>
"@

            }

$page += @"
          </tbody>
        </table>
      </div>
    </div>
 
    <div class="bg-white rounded-lg shadow p-4 overflow-x-auto mb-6">
      <h2 class="text-xl font-semibold mb-3">Contact summary</h2>
      <table class="min-w-full table-auto text-sm border-collapse">
        <thead class="bg-gray-50 text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200">
          <tr>
            <th class="px-3 py-2 text-left">Display Name</th>
            <th class="px-3 py-2 text-left">External Address</th>
            <th class="px-3 py-2 text-left">Type</th>
            <th class="px-3 py-2 text-left">Synced</th>
            <th class="px-3 py-2 text-left">Hidden</th>
            <th class="px-3 py-2 text-left">Direct</th>
            <th class="px-3 py-2 text-left">Total</th>
            <th class="px-3 py-2 text-left">Groups</th>
          </tr>
        </thead>
        <tbody>
"@


            foreach ($row in $summary) {
                $rowClass = if ($row.TotalGroupCount -eq 0) { 'bg-red-50 hover:bg-amber-50 border-b border-gray-100' } else { 'odd:bg-white even:bg-gray-50 hover:bg-amber-50 border-b border-gray-100' }
$page += @"
          <tr class="$rowClass">
            <td class="px-3 py-2 whitespace-nowrap">$(ConvertTo-MMHtmlText $row.DisplayName)</td>
            <td class="px-3 py-2 whitespace-nowrap">$(ConvertTo-MMHtmlText $row.ExternalEmailAddress)</td>
            <td class="px-3 py-2 whitespace-nowrap">$($row.RecipientTypeDetails)</td>
            <td class="px-3 py-2 whitespace-nowrap">$($row.IsDirSynced)</td>
            <td class="px-3 py-2 whitespace-nowrap">$($row.HiddenFromAddressListsEnabled)</td>
            <td class="px-3 py-2 whitespace-nowrap">$($row.DirectGroupCount)</td>
            <td class="px-3 py-2 whitespace-nowrap">$($row.TotalGroupCount)</td>
            <td class="px-3 py-2">$(ConvertTo-MMHtmlText $row.Groups)</td>
          </tr>
"@

            }

$page += @"
        </tbody>
      </table>
    </div>
 
    <div class="bg-white rounded-lg shadow p-4 overflow-x-auto">
      <h2 class="text-xl font-semibold mb-3">Detailed assignments</h2>
      <table class="min-w-full table-auto text-sm border-collapse">
        <thead class="bg-gray-50 text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200">
          <tr>
            <th class="px-3 py-2 text-left">Display Name</th>
            <th class="px-3 py-2 text-left">External Address</th>
            <th class="px-3 py-2 text-left">Group</th>
            <th class="px-3 py-2 text-left">Group Address</th>
            <th class="px-3 py-2 text-left">Group Type</th>
            <th class="px-3 py-2 text-left">Membership</th>
            <th class="px-3 py-2 text-left">Via</th>
          </tr>
        </thead>
        <tbody>
"@


            foreach ($row in $expandedRows) {
$page += @"
          <tr class="odd:bg-white even:bg-gray-50 hover:bg-amber-50 border-b border-gray-100">
            <td class="px-3 py-2 whitespace-nowrap">$(ConvertTo-MMHtmlText $row.DisplayName)</td>
            <td class="px-3 py-2 whitespace-nowrap">$(ConvertTo-MMHtmlText $row.ExternalEmailAddress)</td>
            <td class="px-3 py-2 whitespace-nowrap">$(ConvertTo-MMHtmlText $row.Group)</td>
            <td class="px-3 py-2 whitespace-nowrap">$(ConvertTo-MMHtmlText $row.GroupSmtpAddress)</td>
            <td class="px-3 py-2 whitespace-nowrap">$($row.GroupType)</td>
            <td class="px-3 py-2 whitespace-nowrap">$($row.MembershipType)</td>
            <td class="px-3 py-2">$(ConvertTo-MMHtmlText $row.Via)</td>
          </tr>
"@

            }

$page += @"
        </tbody>
      </table>
    </div>
  </div>
 
  <script>
    const typeLabels = $chartLabelsString;
    const typeData = $chartDataString;
    const ctx = document.getElementById('typeChart');
    if (ctx && typeLabels.length > 0) {
      new Chart(ctx, {
        type: 'doughnut',
        data: {
          labels: typeLabels,
          datasets: [{
            data: typeData,
            backgroundColor: ['#EF4444','#3B82F6','#10B981','#F59E0B','#8B5CF6','#EC4899'],
            borderWidth: 1
          }]
        },
        options: { plugins: { legend: { position: 'bottom' } } }
      });
    }
  </script>
</body>
</html>
"@


            $page | Set-Content -Path $htmlPath -Encoding UTF8
            Write-Verbose "Report exported to $htmlPath"
        }
    }
    #endregion

    foreach ($item in $output) { $item }
}