Private/Show-AACAdvisorTable.ps1

function Show-AACAdvisorTable {
    <#
    .SYNOPSIS
        Renders AAC.AdvisorRecommendation objects as colour-coded
        Spectre.Console tables, one per Advisor category.
    .DESCRIPTION
        Each category gets its own rounded table, bordered in the category's
        colour, titled with its counts and estimated savings:
 
          ╭──────── ● Cost · 12 recommendations · 12 resources · USD 6,738 / month ────────╮
          │ Impact │ Recommendation │ Resource │ Subscription · RG │ Savings / retirement │
          │ HIGH │ Right-size underused VMs │ vm-app-15 │ sub-dev · rg-app │ USD 262.50 / mo │
          │ │ │ virtualMachines │ │ │
          │ │ │ vm-app-30 │ sub-prod · rg-app │ USD 525.00 / mo │
          ...
 
        Rows are grouped by recommendation: the impact badge (HIGH on red,
        MEDIUM on orange, LOW on grey) and the recommendation text appear on
        a group's first row only, with an empty row between groups. Savings
        are green; a retirement date is red within 90 days (or past), orange
        within 180 days and gold after that. Postponed and dismissed
        recommendations are marked in orange under the resource.
 
        The objects must arrive sorted by category, impact and problem, as
        Get-AACAdvisorRecommendation returns them. Output goes straight to
        the Spectre console, so wrap the call in Invoke-AACPagedOutput to
        page it.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]] $Recommendation
    )

    $escape = { param($Text) [Spectre.Console.Markup]::Escape([string]$Text) }
    # Unicode symbols, or ASCII in a console that isn't UTF-8.
    $glyph = Get-AACGlyph
    $categories = [ordered]@{
        Cost                  = @{ Label = 'Cost'; Color = 'green3' }
        Security              = @{ Label = 'Security'; Color = 'indianred1' }
        Reliability           = @{ Label = 'Reliability'; Color = 'deepskyblue1' }
        OperationalExcellence = @{ Label = 'Operational excellence'; Color = 'mediumpurple2' }
        Performance           = @{ Label = 'Performance'; Color = 'gold1' }
    }
    $badge = @{
        High   = '[bold white on red3] HIGH [/]'
        Medium = '[bold black on orange1] MEDIUM [/]'
        Low    = '[black on grey70] LOW [/]'
    }
    $today = (Get-Date).Date

    $savingsText = {
        param($Items)
        (@($Items | Where-Object { $null -ne $_.MonthlySavings }) |
            Group-Object -Property SavingsCurrency |
            Sort-Object -Property Name |
            ForEach-Object { ('{0} {1:N0}' -f $_.Name, ($_.Group | Measure-Object -Property MonthlySavings -Sum).Sum).Trim() }) -join ' + '
    }
    $resourceKey = { if ($_.ResourceId) { $_.ResourceId.ToLowerInvariant() } else { $_.ResourceName } }

    # Categories in the portal's order, then anything unexpected.
    $groups = @($Recommendation | Group-Object -Property Category | Sort-Object {
            $index = @($categories.Keys).IndexOf($_.Name)
            if ($index -lt 0) { 99 } else { $index }
        })

    foreach ($group in $groups) {
        $style = $categories[$group.Name]
        if (-not $style) { $style = @{ Label = $group.Name; Color = 'grey70' } }
        $color = $style.Color
        $items = @($group.Group)

        $title = [System.Collections.Generic.List[string]]::new()
        $title.Add("[bold $color]$($glyph.Bullet) $(& $escape $style.Label)[/]")
        $title.Add("$($items.Count) recommendation$(if ($items.Count -ne 1) { 's' })")
        $resourceCount = @($items | ForEach-Object $resourceKey | Select-Object -Unique).Count
        $title.Add("$resourceCount resource$(if ($resourceCount -ne 1) { 's' })")
        $saving = & $savingsText $items
        if ($saving) { $title.Add("[green3]$(& $escape $saving) / month[/]") }

        $table = [Spectre.Console.Table]::new()
        $table.Border = [Spectre.Console.TableBorder]::Rounded
        $table.BorderStyle = [Spectre.Console.Style]::Parse($color)
        $table.Expand = $true
        $table.Title = [Spectre.Console.TableTitle]::new(($title -join " [grey58]$($glyph.Dot)[/] "))

        $addColumn = {
            param([string] $Header, [switch] $NoWrap, [int] $Width = 0)
            $column = [Spectre.Console.TableColumn]::new("[grey62]$Header[/]")
            $column.NoWrap = [bool]$NoWrap
            if ($Width -gt 0) { $column.Width = $Width }
            $table.AddColumn($column) | Out-Null
        }
        & $addColumn 'Impact' -NoWrap -Width 8
        & $addColumn 'Recommendation'
        & $addColumn 'Resource'
        & $addColumn 'Subscription / resource group'
        & $addColumn 'Savings / retirement' -Width 24

        $previousKey = $null
        foreach ($item in $items) {
            $key = "$($item.Impact)|$($item.Problem)"
            $firstInGroup = $key -ne $previousKey
            if ($firstInGroup -and $null -ne $previousKey) {
                [Spectre.Console.TableExtensions]::AddEmptyRow($table) | Out-Null
            }
            $previousKey = $key

            $impactCell = if ($firstInGroup) { $(if ($badge.ContainsKey([string]$item.Impact)) { $badge[[string]$item.Impact] } else { "[grey70]$(& $escape $item.Impact)[/]" }) } else { '' }
            $problemCell = if ($firstInGroup) { "[white]$(& $escape $item.Problem)[/]" } else { '' }

            $resourceLines = [System.Collections.Generic.List[string]]::new()
            $resourceLines.Add("[bold]$(& $escape $(if ($item.ResourceName) { $item.ResourceName } else { '-' }))[/]")
            if ($item.ResourceType) { $resourceLines.Add("[grey50]$(& $escape ($item.ResourceType -split '/')[-1])[/]") }
            if ($item.Status -and $item.Status -ne 'Active') {
                $until = if ($item.SuppressionExpires) { " until $($item.SuppressionExpires.ToString('d MMM yyyy'))" } else { '' }
                $resourceLines.Add("[italic orange1]$(& $escape ([string]$item.Status).ToLowerInvariant())$until[/]")
            }

            $where = if ($item.SubscriptionName) { $item.SubscriptionName } else { $item.SubscriptionId }
            if ($item.ResourceGroup) { $where = "$where $($glyph.Dot) $($item.ResourceGroup)" }

            $notes = [System.Collections.Generic.List[string]]::new()
            if ($null -ne $item.MonthlySavings) {
                $notes.Add("[green3]$(& $escape (('{0} {1:N2}' -f $item.SavingsCurrency, $item.MonthlySavings).Trim()))[/][grey58] / mo[/]")
            }
            elseif ($null -ne $item.AnnualSavings) {
                $notes.Add("[green3]$(& $escape (('{0} {1:N2}' -f $item.SavingsCurrency, $item.AnnualSavings).Trim()))[/][grey58] / yr[/]")
            }
            if ($item.RetirementDate) {
                $days = [int][Math]::Floor(($item.RetirementDate.Date - $today).TotalDays)
                $dateColor = if ($days -lt 90) { 'bold red1' } elseif ($days -lt 180) { 'orange1' } else { 'gold1' }
                $when = if ($days -lt 0) { 'retired' } else { "$days days" }
                $notes.Add("[$dateColor]$($item.RetirementDate.ToString('d MMM yyyy'))[/] [grey58]($when)[/]")
                if ($item.RetiringFeature) { $notes.Add("[grey58]$(& $escape $item.RetiringFeature)[/]") }
            }

            [Spectre.Console.TableExtensions]::AddRow($table, [Spectre.Console.Rendering.IRenderable[]]@(
                    [Spectre.Console.Markup]::new($impactCell)
                    [Spectre.Console.Markup]::new($problemCell)
                    [Spectre.Console.Markup]::new(($resourceLines -join "`n"))
                    [Spectre.Console.Markup]::new("[grey70]$(& $escape $where)[/]")
                    [Spectre.Console.Markup]::new($(if ($notes.Count) { $notes -join "`n" } else { '[grey42]-[/]' }))
                )) | Out-Null
        }

        [Spectre.Console.AnsiConsole]::WriteLine()
        [Spectre.Console.AnsiConsole]::Write($table)
    }
}