Private/Write-AACGroupMembershipHtml.ps1

function Write-AACGroupMembershipHtml {
    <#
    .SYNOPSIS
        Writes Get-AACEntraGroupMembership's result as an interactive HTML
        report: tiles and charts that filter the tables, a table of groups
        and one of every membership - searchable, filterable and
        downloadable as CSV.
    #>

    [CmdletBinding()]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(Mandatory)]
        [hashtable] $Membership,

        [Parameter(Mandatory)]
        [string] $Path,

        [Parameter(Mandatory)]
        [string] $Title,

        [System.Collections.IDictionary] $Detail
    )

    $stats = $Membership.Stats
    $rows = @($Membership.Rows | ForEach-Object {
            $_ | Select-Object -Property *, @{ Name = 'Account'; Expression = { if ($_.MemberType -ne 'User') { '' } elseif ($_.AccountEnabled -eq $false) { 'Disabled' } else { 'Enabled' } } }
        })
    $users = @($rows | Where-Object MemberType -EQ 'User')
    $top = {
        param([object[]] $Items, [string] $Property, [int] $First = 12)
        @($Items | Group-Object -Property $Property | Sort-Object -Property @{ Expression = 'Count'; Descending = $true }, Name | Select-Object -First $First | ForEach-Object {
                @{ Label = $(if ($_.Name) { $_.Name } else { '(none)' }); Value = $_.Count; Filter = $_.Name }
            })
    }

    $tiles = @(
        @{ Value = '{0:N0}' -f $stats.Groups; Label = 'groups'; Tone = 'violet'; Table = 'groups' }
        @{ Value = '{0:N0}' -f $stats.Rows; Label = 'membership rows'; Tone = 'neutral'; Table = 'members' }
        @{ Value = '{0:N0}' -f $stats.Users; Label = 'unique users'; Tone = 'info'; Table = 'members'; Filters = @{ MemberType = 'User' } }
        @{ Value = '{0:N0}' -f $stats.Guests; Label = 'guests'; Tone = $(if ($stats.Guests) { 'warn' } else { 'good' }); Table = 'members'; Filters = @{ UserType = 'Guest' } }
        @{ Value = '{0:N0}' -f $stats.Disabled; Label = 'disabled accounts'; Tone = $(if ($stats.Disabled) { 'bad' } else { 'good' }); Table = 'members'; Filters = @{ Account = 'Disabled' } }
        @{ Value = '{0:N0}' -f $stats.NestedGroups; Label = 'nested groups'; Tone = 'warn'; Table = 'members'; Filters = @{ MemberType = 'Group' } }
    )
    if ($stats.EmptyGroups) { $tiles += @{ Value = '{0:N0}' -f $stats.EmptyGroups; Label = 'empty groups'; Tone = 'bad'; Table = 'members'; Filters = @{ Membership = 'Empty' } } }

    $charts = @(
        @{ Title = 'Users by group'; Items = @(& $top $users 'GroupName'); Table = 'members'; Column = 'GroupName'; Tone = 'info'; Wide = $true }
        @{ Title = 'Members by type'; Items = @(& $top @($rows | Where-Object MemberType) 'MemberType'); Table = 'members'; Column = 'MemberType' }
        @{ Title = 'Users: members and guests'; Items = @(& $top $users 'UserType'); Table = 'members'; Column = 'UserType'; Tone = 'warn' }
        @{ Title = 'Direct or through a nested group'; Items = @(& $top @($rows | Where-Object MemberType) 'Membership'); Table = 'members'; Column = 'Membership' }
    )

    $tables = @(
        @{
            Id = 'groups'; Title = 'Groups'; Noun = 'groups'; File = 'groups'; Rows = @($Membership.Groups); GroupBy = @('GroupType', 'GroupSource')
            Sort = @{ Key = 'Users'; Desc = $true }
            Columns = @(
                @{ Key = 'GroupName'; Label = 'Group' }
                @{ Key = 'GroupType'; Label = 'Type'; Facet = $true }
                @{ Key = 'GroupSource'; Label = 'Source'; Facet = $true }
                @{ Key = 'DirectMembers'; Label = 'Direct members'; Type = 'number'; Sum = $true; Format = 'N0' }
                @{ Key = 'NestedGroups'; Label = 'Nested groups'; Type = 'number'; Sum = $true; Format = 'N0' }
                @{ Key = 'Users'; Label = 'Users (all levels)'; Type = 'number'; Sum = $true; Format = 'N0' }
                @{ Key = 'Guests'; Label = 'Guests'; Type = 'number'; Sum = $true; Format = 'N0'; Tone = 'warn' }
                @{ Key = 'DisabledUsers'; Label = 'Disabled'; Type = 'number'; Sum = $true; Format = 'N0'; Tone = 'bad' }
                @{ Key = 'Description'; Label = 'Description'; Type = 'wide' }
                @{ Key = 'MembershipRule'; Label = 'Membership rule'; Type = 'wide' }
                @{ Key = 'Mail'; Label = 'Mail' }
                @{ Key = 'Error'; Label = 'Could not read'; Type = 'wide' }
                @{ Key = 'GroupId'; Label = 'Group ID'; Type = 'mono'; Hidden = $true }
            )
        }
        @{
            Id = 'members'; Title = 'Memberships'; Note = 'One row per group and member. Nested: through a group nested in it - Via shows the path.'
            Noun = 'rows'; File = 'group-membership'; Rows = $rows; GroupBy = @('GroupName', 'MemberType', 'UserType', 'Membership')
            Columns = @(
                @{ Key = 'GroupName'; Label = 'Group'; Facet = $true }
                @{ Key = 'MemberName'; Label = 'Member' }
                @{ Key = 'MemberType'; Label = 'Type'; Type = 'badge'; Tones = @{ User = 'info'; Group = 'warn'; 'Service principal' = 'violet'; Device = 'neutral' }; Facet = $true }
                @{ Key = 'UserPrincipalName'; Label = 'User principal name' }
                @{ Key = 'UserType'; Label = 'Member or guest'; Type = 'badge'; Tones = @{ Guest = 'warn'; Member = 'good' }; Facet = $true }
                @{ Key = 'Account'; Label = 'Account'; Type = 'badge'; Tones = @{ Disabled = 'bad'; Enabled = 'good' }; Facet = $true }
                @{ Key = 'Membership'; Label = 'Membership'; Type = 'badge'; Tones = @{ Direct = 'good'; Nested = 'info'; Empty = 'warn'; 'Not read' = 'bad' }; Facet = $true }
                @{ Key = 'Via'; Label = 'Via'; Type = 'wide' }
                @{ Key = 'Mail'; Label = 'Mail' }
                @{ Key = 'JobTitle'; Label = 'Job title' }
                @{ Key = 'Department'; Label = 'Department'; Facet = $true }
                @{ Key = 'GroupType'; Label = 'Group type'; Facet = $true }
                @{ Key = 'GroupSource'; Label = 'Group source'; Facet = $true }
                @{ Key = 'MemberId'; Label = 'Member ID'; Type = 'mono'; Hidden = $true }
                @{ Key = 'GroupId'; Label = 'Group ID'; Type = 'mono'; Hidden = $true }
            )
        }
    )
    $notices = @()
    if ($stats.Unreadable) { $notices += @{ Tone = 'warn'; Text = "The members of $($stats.Unreadable) group(s) couldn't be read; the Groups table says why." } }
    $notices += @{ Tone = 'info'; Text = 'Users are counted once per group however many nested groups they are in. Microsoft Graph, read with delegated Group.Read.All and User.Read.All.' }

    Write-AACHtmlReport -Path $Path -Title $Title -Subtitle 'Entra ID groups and everyone in them - direct and nested' -Fact $Detail -Tile $tiles -Chart $charts -Table $tables -Notice $notices
}